Tabs

The APG tabs pattern: one list of tabs over one panel at a time, with selection following focus and the arrows following the reading order.
Import
JSX
import Tabs from '@box-kite/react/components/tabs';
Usage
Who is on it, and what is left.
JSX
<Tabs defaultValue="overview">
  <Tabs.List label="Project">
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="activity">Activity</Tabs.Tab>
    <Tabs.Tab value="settings">Settings</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview">Who is on it, and what is left.</Tabs.Panel>
  <Tabs.Panel value="activity">What changed this week.</Tabs.Panel>
  <Tabs.Panel value="settings">Who is allowed to change it.</Tabs.Panel>
</Tabs>

Selection follows focus

One arrow key moves to a tab and shows its panel, which is APG's default and what a reader expects. The three things the pattern asks of the keyboard are all here:
One tab stop for the whole list
Tab enters the list once, landing on the selected tab, and again leaves it for the panel — the roving tabindex, so a twelve-tab list is not twelve stops on the way down the page.
The arrows follow the reading order
Right and Left in a horizontal list, Down and Up in a vertical one, wrapping at the ends and stepping over a disabled tab. In a right-to-left page ArrowLeft is the next tab. The off-axis pair is left to the page, so a horizontal list does not eat a scroll.
Manual activation, when a panel is expensive
activation="manual" splits focus from selection: the arrows move, and Enter or Space chooses. Nothing renders on the way past.

Manual activation

Arrow across these and nothing happens until Enter or Space — worth it when a panel fetches or renders something heavy, and wrong otherwise, since it costs the reader a keystroke per tab.
Today, hour by hour.
JSX
<Tabs defaultValue="daily" activation="manual">
  <Tabs.List label="Report">
    <Tabs.Tab value="daily">Daily</Tabs.Tab>
    <Tabs.Tab value="weekly">Weekly</Tabs.Tab>
    <Tabs.Tab value="yearly">Yearly</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="daily">Today, hour by hour.</Tabs.Panel>
  <Tabs.Panel value="weekly">This week, day by day.</Tabs.Panel>
  <Tabs.Panel value="yearly">Twelve months of it.</Tabs.Panel>
</Tabs>

Vertical

orientation="vertical" turns the widget's axis, moves the arrows to Down and Up, and puts the indicator on the inline end — where it mirrors in a right-to-left page, which a border on the right would not.
Name, description, visibility.
JSX
<Tabs defaultValue="general" orientation="vertical">
  <Tabs.List label="Settings">
    <Tabs.Tab value="general">General</Tabs.Tab>
    <Tabs.Tab value="members">Members</Tabs.Tab>
    <Tabs.Tab value="billing">Billing</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="general">Name, description, visibility.</Tabs.Panel>
  <Tabs.Panel value="members">Who is in, and what they may do.</Tabs.Panel>
  <Tabs.Panel value="billing">The plan and the invoices.</Tabs.Panel>
</Tabs>

An indicator that travels

indicator="sliding" replaces the border each tab draws with one element for the whole list, which animates between tabs because it is the same element. Where it goes is measured, so it appears once the widget has run — and until then the tabs keep drawing their own, which is what a prerendered page paints and what a reader whose JavaScript never arrives keeps. The travel rides --transitionTime, so prefers-reduced-motion stops it with no opt-out.
Who is on it, and what is left.
JSX
<Tabs defaultValue="overview" indicator="sliding">
  <Tabs.List label="Project">
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="activity">Activity</Tabs.Tab>
    <Tabs.Tab value="members">Members and permissions</Tabs.Tab>
    <Tabs.Tab value="logs">Logs</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview">Who is on it, and what is left.</Tabs.Panel>
  <Tabs.Panel value="activity">What changed this week.</Tabs.Panel>
  <Tabs.Panel value="members">Who is allowed in, and to do what.</Tabs.Panel>
  <Tabs.Panel value="logs">Every build, newest first.</Tabs.Panel>
</Tabs>
It turns with the list: a vertical one puts the bar on the inline end, which is the right-hand side of a left-to-right page and the left of a right-to-left one.
What changed.
JSX
<Tabs defaultValue="activity" orientation="vertical" indicator="sliding">
  <Tabs.List label="Views">
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="activity">Activity</Tabs.Tab>
    <Tabs.Tab value="logs">Logs</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview">Who is on it.</Tabs.Panel>
  <Tabs.Panel value="activity">What changed.</Tabs.Panel>
  <Tabs.Panel value="logs">Every build.</Tabs.Panel>
</Tabs>

Panels that resize smoothly

Panels of different heights make the page jump under them. Wrap them in a Tabs.Panels and that container takes the height of the panel on screen, so the change is a transition instead. It is the only optional part — panels work as plain siblings of the list without it.
One line, and the box is one line tall.
JSX
<Tabs defaultValue="short" indicator="sliding">
  <Tabs.List label="Release">
    <Tabs.Tab value="short">Summary</Tabs.Tab>
    <Tabs.Tab value="long">Changelog</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel value="short">One line, and the box is one line tall.</Tabs.Panel>
    <Tabs.Panel value="long">
      <Flex d="column" gap={2}>
        <Box>The container measures whichever panel is showing.</Box>
        <Box>It clips only while the height is travelling.</Box>
        <Box>At rest nothing is clipped, so a focus ring at the edge survives.</Box>
        <Box>And the wait is the CSS on the element, so reduced motion has none.</Box>
      </Flex>
    </Tabs.Panel>
  </Tabs.Panels>
</Tabs>
It measures the panel, never itself
The container writes its own height, so watching that height would be the loop. What it watches is the panel — every reflow of it, not only a switch — which is how a wrapped line or a late font moves the box with it.
Clipped only while it is moving
A panel already at its full height inside a container still on the way there has to be clipped, or it paints over whatever follows. At rest the clip is gone, because the container is exactly as tall as its panel and a permanent one would cut the focus ring off everything sitting at that edge.

Disabled tabs

A disabled tab is not selectable and the arrows step over it — the opposite of Menu.Item, which APG asks stay reachable. Selection follows focus here, so a tab focus could reach and selection could not would leave the widget with no state to be in.
Who is on it.
JSX
<Tabs defaultValue="overview">
  <Tabs.List label="Project">
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="audit" disabled>Audit log</Tabs.Tab>
    <Tabs.Tab value="activity">Activity</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview">Who is on it.</Tabs.Panel>
  <Tabs.Panel value="activity">What changed.</Tabs.Panel>
</Tabs>

Only the selected panel is rendered

An unmounted panel costs nothing and gets an entrance for free, since startingStyle runs on the mount — but it also loses whatever state it held. keepMounted renders them all and hides the rest, which is what a panel holding a half-filled form wants. Type into the first field, switch away and back:
JSX
<Tabs defaultValue="details" keepMounted>
  <Tabs.List label="New project">
    <Tabs.Tab value="details">Details</Tabs.Tab>
    <Tabs.Tab value="access">Access</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="details"><Textbox props={{ 'aria-label': 'Name' }} /></Tabs.Panel>
  <Tabs.Panel value="access"><Textbox props={{ 'aria-label': 'Owner' }} /></Tabs.Panel>
</Tabs>
A hidden panel carries the hidden attribute and a display: none rule of its own, because every Box carries display: block and any author rule outranks the UA's [hidden] one.

Controlled

Pass value and the consumer owns the selection; onValueChange reports the new tab and why it changed — 'click' or 'keyboard'. The tab sequence follows the selection either way, so a value changed from somewhere else on the page moves the keyboard's entry point with it.
What changed.
value: activity
JSX
const [value, setValue] = useState('activity');

<Tabs value={value} onValueChange={(next, { reason }) => { if (next) setValue(next); log(reason); }}>
  …
</Tabs>

A tab is wherever you wrote it

The tabs are read off the DOM rather than out of a registry, so a tab a consumer wrapped in a layout of their own, rendered from a list, or put behind a condition is in the order it was written and navigates like any other — and a nested set of tabs belongs to its own list, not the one around it.
JSX
<Tabs.List label="Project">
  {views.map((view: { id: string; name: string }) => (
    <Tabs.Tab key={view.id} value={view.id}>{view.name}</Tabs.Tab>
  ))}
</Tabs.List>

Styling

Every part is a Box, so every prop is available, and the defaults live in Box.components('tabs') — with tabs.list, tabs.tab and tabs.panel beneath it. The selected state is aria-selected, so ariaAttr={{ selected: … }} is what styles it on a tab — inside a style tree the same state is the bare selected key, but as a prop selected writes the attribute the component owns. The indicator is a border rather than a background, because a forced-colors mode throws every background away and selection would otherwise read identically on and off.
JSX
<Tabs.Tab
  value="overview"
  px={4}
  ariaAttr={{ selected: { color: 'emerald-600', borderColor: 'emerald-500' } }}
>
  Overview
</Tabs.Tab>

Tabs props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
PropTypeDefaultWhat it does
childrenReact.ReactNode—The tabs and their panels: a Tabs.List of Tabs.Tab, then one Tabs.Panel per tab.
valuestring—Controlled selection. Leave it out and the widget owns it.
defaultValuestring—Which tab starts selected. Left out, nothing is selected and no panel is shown until one is chosen.
onValueChangeChangeHandler<string | undefined, TabsReason>—Fires with the newly selected tab and why it changed — 'click' or 'keyboard'.
orientationTabsOrientation'horizontal'Which way the tabs run. Horizontal is the reading axis, so its arrows mirror in a right-to-left page.
activationTabsActivation'automatic'Whether moving to a tab selects it. Default 'automatic', which is what APG asks for.
loopbooleantrueWhether the arrows wrap around at the ends. Default true.
indicatorTabsIndicator'static'Which indicator marks the selected tab: the tab's own border ('static', the default) or one element travelling between them ('sliding'), which animates because it is the same element.
keepMountedbooleanfalseRender every panel rather than only the selected one, hiding the rest. What a panel holding a half-filled form needs, since an unmounted panel loses its state.

Tabs.List

The tabs themselves, and the keyboard: it sits on the list because roving tabindex puts one tab in the tab sequence.
PropTypeDefaultWhat it does
childrenReact.ReactNode—The tabs.
labelstring—The list's accessible name. A tablist has none of its own, and one page can hold several.
labelledBystring—Names the list after an element already on the page, instead of label.

Tabs.Tab

One tab: a real <button>, so Enter and Space reach it the way the platform means them to.
PropTypeDefaultWhat it does
valuerequiredstring—Which panel this tab shows, and what the widget reports when it is chosen.
childrenReact.ReactNode—The tab's label.
disabledboolean—Not selectable, and stepped over by the arrows — unlike a disabled Menu.Item, which APG asks stay reachable. Selection follows focus here, so a tab focus could reach and selection could not would leave the widget with no state to be in.

Tabs.Panels

The panels' container, and the only optional part: it takes the height of the panel on screen, so a switch between panels of different heights is a transition rather than a jump. Panels work as plain siblings of the list without it.
PropTypeDefaultWhat it does
childrenReact.ReactNode—The panels.

Tabs.Panel

One panel. Rendered only while its tab is selected, unless the widget was told to keep them all.
PropTypeDefaultWhat it does
valuerequiredstring—The tab this panel belongs to.
childrenReact.ReactNode—The panel's content.

Tabs keyboard

KeyResult
TabEnters the list once, landing on the selected tab, and again leaves it for the panel.
Right / LeftThe next and previous tab in a horizontal list, wrapping at the ends and stepping over disabled tabs. Mirrored in a right-to-left page.
Down / UpThe same in a vertical list. The off-axis pair is left to the page, so a horizontal list does not eat a scroll.
Home / EndThe first and last tab.
Enter, SpaceChooses the focused tab. Only activation="manual" needs them; automatic activation has already chosen it.

Tabs accessibility

  • role="tablist" on the list, named by its own label — a page can hold several, and a tablist has no name of its own.
  • role="tab" with aria-selected on each tab, and aria-controls naming the panel it shows — written only while that panel is mounted, since a dangling reference names nothing.
  • role="tabpanel" on the panel, aria-labelledby its tab, and tabindex="0" so the content is reachable from the keyboard whether or not anything inside it is focusable.
  • aria-orientation follows orientation, and the arrows follow the reading order: in a right-to-left page ArrowLeft is the *next* tab.
  • The travelling indicator is aria-hidden: it says what aria-selected already says, and it keeps a Highlight fill in a forced-colors mode, so it is decoration in every mode that has colour.
Swept with axe on every release, in 3 states: Tabs, Tabs (vertical, every panel mounted), Tabs (travelling indicator, resizing panels). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

Tabs style tree

Every part the component draws is a node with a name, so a default can be restyled with Box.components() instead of a selector — and a variant is a name too.
tabsvariants: vertical
tabs.listvariants: vertical, sliding
tabs.tabvariants: vertical, underline
tabs.indicatorvariants: vertical
tabs.panelsvariants: resizing
tabs.panelvariants: hidden