Blog

Part 39 · Compound Components: Building Accessible Tabs

Build tabs that work with a mouse, a keyboard and a screen reader. Learn the roles, the arrow keys, the roving tabindex, matching by value, and hidden panels.

In Part 38 we built an accordion from parts that share state through context. We used useId for the ids, and the accordion worked both controlled and uncontrolled.

This part builds tabs in the same way. Tabs show one section of a page at a time. A row of buttons sits on top, and each button shows its own section. Settings pages often use them.

Tabs bring two new problems. First, the buttons and the sections live in two different places in the HTML. Second, keyboard users expect the arrow keys to move between tabs. The W3C is the group that writes web standards. A guide from the W3C gives clear advice for both. We’ll follow it, and measure each step.

We’ll also use a ref to move focus, from Part 14. We’ll use controlled and uncontrolled props, from Part 28. And we’ll choose between hiding and removing, from Part 7.

Try this first

This is the whole tabs component, so it is long. Read App at the bottom first. Don’t press Run yet.

import { createContext, useContext, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'

function nextIndex(key: string, now: number, count: number) {
  if (key === 'ArrowRight') return (now + 1) % count
  if (key === 'ArrowLeft') return (now - 1 + count) % count
  if (key === 'Home') return 0
  if (key === 'End') return count - 1
  return null
}

type TabsState = {
  selected: string
  select: (value: string) => void
  baseId: string
}

const TabsContext = createContext<TabsState | null>(null)

function useTabs() {
  const state = useContext(TabsContext)
  if (state === null) {
    throw new Error('Tab parts must be inside <Tabs>')
  }
  return state
}

function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {
  const [selected, setSelected] = useState(defaultValue)
  const baseId = useId()
  const state = useMemo(() => ({ selected, select: setSelected, baseId }), [selected, baseId])
  return <TabsContext value={state}>{children}</TabsContext>
}

function TabList({ label, labelledBy, children }: { label?: string; labelledBy?: string; children: ReactNode }) {
  const listRef = useRef<HTMLDivElement>(null)

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (listRef.current === null) return
    const tabs = Array.from(listRef.current.querySelectorAll<HTMLElement>('[role="tab"]'))
    const now = tabs.findIndex((tab) => tab === e.target)
    const next = nextIndex(e.key, now, tabs.length)
    if (next === null) return
    e.preventDefault()
    tabs[next].focus()
  }

  return (
    <div role="tablist" aria-label={label} aria-labelledby={labelledBy} ref={listRef} onKeyDown={handleKeyDown}>
      {children}
    </div>
  )
}

function Tab({ value, children }: { value: string; children: ReactNode }) {
  const { selected, select, baseId } = useTabs()
  const isSelected = value === selected
  return (
    <button
      role="tab"
      id={`${baseId}-tab-${value}`}
      aria-controls={`${baseId}-panel-${value}`}
      aria-selected={isSelected}
      tabIndex={isSelected ? 0 : -1}
      style={{ fontWeight: isSelected ? 'bold' : 'normal' }}
      onClick={() => select(value)}
      onFocus={() => select(value)}
    >
      {children}
    </button>
  )
}

function TabPanel({ value, children }: { value: string; children: ReactNode }) {
  const { selected, baseId } = useTabs()
  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${value}`}
      aria-labelledby={`${baseId}-tab-${value}`}
      tabIndex={0}
      hidden={value !== selected}
    >
      {children}
    </div>
  )
}

Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel

export default function App() {
  const headingId = useId()
  return (
    <Tabs defaultValue="profile">
      <header>
        <h1 id={headingId}>My account</h1>
        <Tabs.List labelledBy={headingId}>
          <Tabs.Tab value="profile">Profile</Tabs.Tab>
          <Tabs.Tab value="settings">Settings</Tabs.Tab>
          <Tabs.Tab value="billing">Billing</Tabs.Tab>
        </Tabs.List>
      </header>
      <main>
        <Tabs.Panel value="profile">
          <p>Name: Ana</p>
        </Tabs.Panel>
        <Tabs.Panel value="settings">
          <p>Email me: yes</p>
        </Tabs.Panel>
        <Tabs.Panel value="billing">
          <p>Plan: Free</p>
        </Tabs.Panel>
      </main>
    </Tabs>
  )
}

Make a guess. You’ll click the “Profile” tab, then press the Right arrow key three times. Which section will show at the end?

Now press Run and try it.

Each press moves to the next tab, and that tab’s section shows. After “Billing”, the third press goes back to “Profile”. The last tab wraps around to the first.

If the arrow keys do nothing, the tab doesn’t have focus yet. Focus means the element that gets your key presses, as Part 6 explained. Press the Tab key until you see a ring around a tab. Then try the arrows.

Look at App again. The tabs sit in <header>, and the sections sit in <main>, away from them. No prop joins a tab to its section. Yet each tab found its own section. This part explains how.

The parts of a tabs component

Part 38 used the W3C’s ARIA Authoring Practices Guide for the accordion. The guide has a page on tabs too. It uses three words:

  • Tab list: the row of tabs.
  • Tab: one button in the row. The guide says a tab “serves as a label for one of the tab panels”.
  • Tab panel: the section that a tab shows. Tabs “display one panel of content at a time”.

Our component has one part for each, plus a parent that holds the state:

Part Its job What it puts on the page
<Tabs> keeps the selected value, in context nothing of its own
<Tabs.List> the row; handles the arrow keys <div role="tablist">
<Tabs.Tab value> one tab <button role="tab">
<Tabs.Panel value> one section <div role="tabpanel">

The rest is from Part 38. Tabs.List = TabList is dot notation. It adds the part to the Tabs function. useTabs reads the context, and throws a clear error outside <Tabs>. The context value is made in useMemo. Part 38 gave the reason in A new context value on every render.

Separated subtrees

The tab list is inside <header>, and the panels are inside <main>. Part 8 called a component and everything inside it a subtree. A tag and everything inside it is one too. So the tabs and the panels live in two separate subtrees.

In Part 38’s accordion, each button sat next to its panel, inside one Accordion.Item. Tabs can’t work that way. All the tabs must be in one row, so they can’t sit next to their panels.

So how does a panel know when to show? It doesn’t ask its tab. Both read the same context from <Tabs>, which is above both subtrees. A tab compares its value with selected. So does a panel. The tab with value="settings" and the panel with value="settings" agree, wherever they are in the HTML.

Try it. Press Edit and move the whole <Tabs.List>, with its three tabs, to just below </main>. The row now sits under the panels. We tried this too: clicks and arrow keys worked the same.

Roles, states and ids

Part 38 said a screen reader is a program that reads the page out loud. It can’t see that our buttons look like tabs. The HTML has to say so. Here is the HTML that React made. We put each tag on its own line, so you can read it:

<h1 id="_r_0_">My account</h1>
<div role="tablist" aria-labelledby="_r_0_">
<button role="tab" id="_r_1_-tab-profile" aria-controls="_r_1_-panel-profile" aria-selected="true" tabindex="0" style="font-weight: bold;">Profile</button>
<button role="tab" id="_r_1_-tab-settings" aria-controls="_r_1_-panel-settings" aria-selected="false" tabindex="-1" style="font-weight: normal;">Settings</button>
<button role="tab" id="_r_1_-tab-billing" aria-controls="_r_1_-panel-billing" aria-selected="false" tabindex="-1" style="font-weight: normal;">Billing</button>
</div>
<div role="tabpanel" id="_r_1_-panel-profile" aria-labelledby="_r_1_-tab-profile" tabindex="0">
<p>Name: Ana</p>
</div>
<div role="tabpanel" id="_r_1_-panel-settings" aria-labelledby="_r_1_-tab-settings" tabindex="0" hidden="">
<p>Email me: yes</p>
</div>

We left out <header>, <main> and the third panel. Each attribute comes from the guide:

  • role tells a screen reader what kind of thing a tag is. The guide says each tab has role tab, and sits inside the element with role tablist. Each panel has role tabpanel.
  • aria-labelledby on the tab list names the id of its label. The guide wants the list to have a name. If a visible label is on the page, the list points to it with aria-labelledby. Our <h1> is that label, so App gives it an id from useId and passes the id as labelledBy. With no visible label, use aria-label. That is why TabList also takes label, and later examples use label="My account".
  • aria-selected says which tab is chosen. The guide says the active tab has it set to true. Every other tab has it set to false. React turned aria-selected={true} into the text "true".
  • aria-controls on each tab names its panel’s id. And aria-labelledby on each panel names its tab’s id. So a screen reader can call the panel by its tab’s name.
  • hidden is on the panels that are not selected, so the browser doesn’t show them. MDN’s page on the tab role says the other panels should have hidden until their tab is selected.
  • tabindex is the next section’s topic.

The ids come from useId, as in Part 38. App calls it once for the heading, and Tabs calls it once for the parts. Each part adds -tab- or -panel- and its value. We put two copies of a small tabs component on one page. With useId, all 8 ids were different. When we made the ids from the value alone, like tab-profile, 4 ids were used twice. An id must be unique on the page, so that is a bug.

Only one tab in the Tab order

The Tab key moves focus from one thing to the next on a page. Buttons are stops by default. So a row of ten tabs would take ten presses to get past.

The guide wants something else. When focus moves into the tab list, it “places focus on the active tab element”. The next Tab press goes to the next thing on the page, outside the tab list. So the whole row is one stop. Inside the row, you move with the arrow keys.

tabIndex makes this happen:

  • tabIndex={0} puts an element in the Tab order.
  • tabIndex={-1} takes it out. MDN says the Tab key can’t reach an element like that. Code can still focus it, with focus().

Our Tab gives 0 to the selected tab and -1 to the others: tabIndex={isSelected ? 0 : -1}. When the selection moves, the 0 moves with it. This is called a roving tabindex. “Roving” means moving around.

The guide’s general steps for a roving tabindex move the 0 with focus. When a key moves focus, they set tabindex="-1" on the old element, and tabindex="0" on the one that gets focus. The tabs pattern keeps the 0 on the selected tab instead. Its manual example puts tabindex="-1" on the other tabs. It says this is “so that only the selected (active) tab is in the page Tab sequence”. In our automatic tabs, focus and selection move together, so both rules give the same result.

We checked it in “Try this first”. At the start, only “Profile” had tabindex="0". After one press of the Right arrow, only “Settings” had it.

The panels have tabIndex={0} too, but only for a reason. The guide says a panel needs it in two cases. One: nothing in the panel can take focus. Two: the first thing in it with content can’t take focus. Then Tab can take a screen reader user from the tab into the panel. A panel that starts with a link or a button doesn’t need it. MDN adds one more rule: if any panel in the set needs it, give it to all of them. Our panels start with a <p>, so they need it. We tried it in Chromium, Firefox and WebKit. Tab went from the selected tab to its panel. Shift+Tab went back to the tab.

An everyday example

Think of a street of houses. The Tab key takes you from house to house. The arrow keys move between the rooms of one house. When you come back to a house, you walk into the room you chose last time. You don’t start at the first room.

The exact version

The room you walk into is the selected tab, not the last tab that had focus. In automatic tabs, these are usually the same, because focus selects. With manual tabs, below, they can be different. Focus can be on “Billing” while “Profile” is still selected. Leave the row with Tab, then come back with Shift+Tab. You land on “Profile”, because only it has tabindex="0". We tried this in Chromium, Firefox and WebKit, and all three did it.

Arrow keys: moving focus with a ref

The guide’s keys for a row of tabs are these:

  • The Right arrow “Moves focus to the next tab. If focus is on the last tab element, moves focus to the first tab.”
  • The Left arrow does the same the other way. From the first tab, it goes to the last.
  • Home and End are marked “(Optional)”. Home moves focus to the first tab, and End to the last.

All four live in one small function, at the top of the code:

function nextIndex(key: string, now: number, count: number) {
  if (key === 'ArrowRight') return (now + 1) % count
  if (key === 'ArrowLeft') return (now - 1 + count) % count
  if (key === 'Home') return 0
  if (key === 'End') return count - 1
  return null
}

% is JavaScript’s remainder operator. With 3 tabs, (1 + 1) % 3 is 2, but (2 + 1) % 3 is 0. That is the wrap from the last tab back to the first. For the Left arrow, adding count first keeps the number from going below 0: (0 - 1 + 3) % 3 is 2. Any other key gives null, which means “not one of ours”.

The handler sits on the tab list, not on each tab. Key events bubble up, as Part 6 showed. So a key pressed on any tab reaches the list. Here is what handleKeyDown does:

  1. listRef.current.querySelectorAll('[role="tab"]') finds the tabs on the page, in page order. listRef is a ref to the list’s <div>. Array.from turns what it finds into a normal array.
  2. findIndex finds the tab that got the key. e.target is that tab.
  3. nextIndex picks the next tab. For any other key it gives null, and the handler stops. So Tab, Enter and letters work as usual.
  4. e.preventDefault() stops the browser’s own action for that key. We checked: the Right arrow’s event had defaultPrevented set to true, and a letter’s had false.
  5. tabs[next].focus() moves focus, as Part 14 did with a text box.

Part 14 had a rule: don’t read or write ref.current while rendering. This handler reads it in an event handler, after rendering, so that is fine.

Why ask the page for the tabs, and not keep a list of them? The page always has the real order. If a tab is added, removed or moved, the next key press sees it. That matters below.

Here is one key press after another:

1. Focus is on Profile. It is the selected tab. 2. You press ArrowRight. handleKeyDown moves focus to Settings. 3. Settings got focus, so its onFocus selects it. 4. The Settings panel shows. The other panels are hidden. 5. ArrowRight again: Billing gets focus and is selected. 6. ArrowRight on the last tab wraps around to Profile. role="tablist" Profile aria-selected="true" tabIndex={0} Settings aria-selected="false" tabIndex={-1} Billing aria-selected="false" tabIndex={-1} you press ArrowRight role="tabpanel", the one that is not hidden Name: Ana aria-labelledby: the Profile tab dashed ring: where focus is. Green: the selected tab.

The Right arrow moves focus, and focus selects the tab. Press play, or step through it.

In words:

  1. Focus is on “Profile”. It is selected: aria-selected="true" and tabIndex={0}.
  2. You press the Right arrow. handleKeyDown calls focus() on “Settings”.
  3. “Settings” got focus, so its onFocus calls select. Now “Settings” has "true" and 0, and the others have "false" and -1.
  4. React commits. The Settings panel loses hidden, and the others get it.
  5. The Right arrow again: “Billing” gets focus, and is selected.
  6. The Right arrow on “Billing”, the last tab, wraps around to “Profile”.

Automatic or manual activation

Look at Tab again. It has onFocus={() => select(value)}. So a tab is selected as soon as it gets focus. The arrow keys only move focus, and focus does the selecting.

The guide calls this automatic activation. To activate a tab means to select it and show its panel. The other choice is manual activation. There, the arrow keys only move focus, and you press Space or Enter to activate.

Which one should you use? The guide says: “It is recommended that tabs activate automatically”. It adds a condition: the panels must show without waiting. Say each panel loads its data from a server when it is shown. Then every arrow press would start a load. The guide’s page on keyboards warns that this is very bad for keyboard and screen reader users. So use manual activation when a panel is slow to show.

To make our tabs manual, delete the onFocus line. A <button> already handles Space and Enter. MDN says its click event fires for mouse clicks. It also fires “when the user presses Space or Enter while the button has focus”. We tried manual tabs in Chromium, Firefox and WebKit. Two presses of the Right arrow moved focus to “Billing”, and “Profile” stayed selected. Then Enter selected “Billing”, and so did Space.

Matching tabs to panels: by value, not by position

Our parts match by value. Another way is common. Each part gets a number from its position. The first tab is 0, the second is 1, and the panels count the same way. The user writes no values at all. The kata this part follows does this. It is called implicit indexing. “Implicit” means nobody writes the number down.

Part 38 showed what goes wrong with index numbers that people type in. Here nobody types them, so nobody can type a wrong one. Below is a small version of the kata’s idea. Ordered hands out 0, 1, 2 to its children, in the order they render. A tab or a panel asks for a number on its first render. It keeps the number in a ref. Read App: the Billing tab and its panel show only after you press “Add a tab”.

import { createContext, useContext, useRef, useState, type ReactNode } from 'react'

const TabsContext = createContext({ selected: 0, select: (index: number) => {} })
const OrderContext = createContext({ register: (): number => 0 })

function Tabs({ children }: { children: ReactNode }) {
  const [selected, select] = useState(0)
  return <TabsContext value={{ selected, select }}>{children}</TabsContext>
}

// Hands out 0, 1, 2... in the order its children render.
function Ordered({ children }: { children: ReactNode }) {
  const counter = useRef(0)
  counter.current = 0
  return <OrderContext value={{ register: () => counter.current++ }}>{children}</OrderContext>
}

function Tab({ children }: { children: ReactNode }) {
  const { selected, select } = useContext(TabsContext)
  const { register } = useContext(OrderContext)
  const index = useRef(-1)
  if (index.current === -1) index.current = register()
  return (
    <button role="tab" aria-selected={index.current === selected} onClick={() => select(index.current)}>
      {children}
    </button>
  )
}

function Panel({ children }: { children: ReactNode }) {
  const { selected } = useContext(TabsContext)
  const { register } = useContext(OrderContext)
  const index = useRef(-1)
  if (index.current === -1) index.current = register()
  if (index.current !== selected) return null
  return <div role="tabpanel">{children}</div>
}

export default function App() {
  const [showBilling, setShowBilling] = useState(false)
  return (
    <Tabs>
      <button onClick={() => setShowBilling(true)}>Add a tab</button>
      <Ordered>
        <div role="tablist">
          <Tab>Profile</Tab>
          {showBilling && <Tab>Billing</Tab>}
          <Tab>Settings</Tab>
        </div>
      </Ordered>
      <Ordered>
        <Panel>
          <p>Name: Ana</p>
        </Panel>
        {showBilling && (
          <Panel>
            <p>Plan: Free</p>
          </Panel>
        )}
        <Panel>
          <p>Email me: yes</p>
        </Panel>
      </Ordered>
    </Tabs>
  )
}

Run it. Click “Settings”, then “Profile”. Both work. Now press “Add a tab”, then click the new “Billing” tab.

Two tabs say they are selected, “Profile” and “Billing”. Two panels show: “Name: Ana” and “Plan: Free”. We got the same result with Strict Mode on and off.

Here is why. Pressing “Add a tab” makes App render again, so each Ordered starts counting from 0 again. “Profile” and “Settings” already have their numbers, 0 and 1, so they don’t ask. “Billing” is new. It asks, and gets 0. Now two tabs are number 0. The new Billing panel gets 0 in the same way.

We also ran the kata’s own code. A tab added in the middle did the same thing. Two tabs even got the same id, ending in -tab-0. The numbers in refs don’t shift when a tab is added. So the new tab gets a number that is already used.

Moving tabs causes a different problem. We gave the kata’s tabs and panels keys, and moved “Settings” to the front. Each part kept its number, so clicks still worked: we clicked all three, and each showed its own panel. Then we put focus on “Profile”, number 0, and pressed the Right arrow. The kata’s code added 1 to 0, and selected the tab numbered 1. That is “Settings”. Then it focused the second tab on the page. That is “Profile”. So “Settings” was selected, but focus stayed on “Profile”.

Why does the kata keep the number in a ref? We tried it without one, so each render asked for a new number. With Strict Mode on, no tab was selected at the start.

The ref also breaks Part 14’s rule. Part 14 allowed one exception: filling a ref once. if (index.current === -1) looks like that. But register() is not filling this component’s own ref. It changes counter.current, a ref of another component, while rendering. Ordered also writes counter.current = 0 on every render. And Tab reads index.current in its JSX.

Now the same app, matched by value:

import { createContext, useContext, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'

function nextIndex(key: string, now: number, count: number) {
  if (key === 'ArrowRight') return (now + 1) % count
  if (key === 'ArrowLeft') return (now - 1 + count) % count
  if (key === 'Home') return 0
  if (key === 'End') return count - 1
  return null
}

type TabsState = {
  selected: string
  select: (value: string) => void
  baseId: string
}

const TabsContext = createContext<TabsState | null>(null)

function useTabs() {
  const state = useContext(TabsContext)
  if (state === null) {
    throw new Error('Tab parts must be inside <Tabs>')
  }
  return state
}

function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {
  const [selected, setSelected] = useState(defaultValue)
  const baseId = useId()
  const state = useMemo(() => ({ selected, select: setSelected, baseId }), [selected, baseId])
  return <TabsContext value={state}>{children}</TabsContext>
}

function TabList({ label, labelledBy, children }: { label?: string; labelledBy?: string; children: ReactNode }) {
  const listRef = useRef<HTMLDivElement>(null)

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (listRef.current === null) return
    const tabs = Array.from(listRef.current.querySelectorAll<HTMLElement>('[role="tab"]'))
    const now = tabs.findIndex((tab) => tab === e.target)
    const next = nextIndex(e.key, now, tabs.length)
    if (next === null) return
    e.preventDefault()
    tabs[next].focus()
  }

  return (
    <div role="tablist" aria-label={label} aria-labelledby={labelledBy} ref={listRef} onKeyDown={handleKeyDown}>
      {children}
    </div>
  )
}

function Tab({ value, children }: { value: string; children: ReactNode }) {
  const { selected, select, baseId } = useTabs()
  const isSelected = value === selected
  return (
    <button
      role="tab"
      id={`${baseId}-tab-${value}`}
      aria-controls={`${baseId}-panel-${value}`}
      aria-selected={isSelected}
      tabIndex={isSelected ? 0 : -1}
      style={{ fontWeight: isSelected ? 'bold' : 'normal' }}
      onClick={() => select(value)}
      onFocus={() => select(value)}
    >
      {children}
    </button>
  )
}

function TabPanel({ value, children }: { value: string; children: ReactNode }) {
  const { selected, baseId } = useTabs()
  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${value}`}
      aria-labelledby={`${baseId}-tab-${value}`}
      tabIndex={0}
      hidden={value !== selected}
    >
      {children}
    </div>
  )
}

Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel

export default function App() {
  const [showBilling, setShowBilling] = useState(false)
  return (
    <Tabs defaultValue="profile">
      <button onClick={() => setShowBilling(true)}>Add a tab</button>
      <Tabs.List label="My account">
        <Tabs.Tab value="profile">Profile</Tabs.Tab>
        {showBilling && <Tabs.Tab value="billing">Billing</Tabs.Tab>}
        <Tabs.Tab value="settings">Settings</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel value="profile">
        <p>Name: Ana</p>
      </Tabs.Panel>
      {showBilling && (
        <Tabs.Panel value="billing">
          <p>Plan: Free</p>
        </Tabs.Panel>
      )}
      <Tabs.Panel value="settings">
        <p>Email me: yes</p>
      </Tabs.Panel>
    </Tabs>
  )
}

Press “Add a tab”, then click “Billing”. Only “Billing” is selected, and only its panel shows. Now click “Profile” and press the Right arrow. Focus goes to “Billing”, the new middle tab. handleKeyDown asked the page for the tabs, so it saw the new order.

An everyday example

Think of coats on a row of hooks. Say you remember “the third coat from the left”. Then a new coat in the middle makes you take the wrong one. If you remember “the coat with my name on it”, it doesn’t matter where it hangs.

The exact version

A name only works if it is unique. Two tabs with the same value are both selected. We tried it: clicking one selected both, and both had tabindex="0". They also share an id, because the value is part of it. Nothing warns you. So give every tab its own value. Keep it to one word with no spaces, because it goes into an id.

Controlled and uncontrolled

Part 38 made its accordion work controlled or uncontrolled, like the inputs in Part 28. It used three props: value, defaultValue and onValueChange. Those are the names Radix UI uses, a well-known library of parts like these. Its Tabs use the same three. Radix’s tabs also take activationMode, "automatic" or "manual". The kata called these props index, defaultIndex and onChange. We changed the names, because ours hold a value, not an index. Only Tabs changes:

import { createContext, useContext, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'

function nextIndex(key: string, now: number, count: number) {
  if (key === 'ArrowRight') return (now + 1) % count
  if (key === 'ArrowLeft') return (now - 1 + count) % count
  if (key === 'Home') return 0
  if (key === 'End') return count - 1
  return null
}

type TabsState = {
  selected: string
  select: (value: string) => void
  baseId: string
}

const TabsContext = createContext<TabsState | null>(null)

function useTabs() {
  const state = useContext(TabsContext)
  if (state === null) {
    throw new Error('Tab parts must be inside <Tabs>')
  }
  return state
}

type TabsProps = {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  children: ReactNode
}

function Tabs({ value, defaultValue = '', onValueChange, children }: TabsProps) {
  const [ownValue, setOwnValue] = useState(defaultValue)
  const isControlled = value !== undefined
  const selected = isControlled ? value : ownValue
  const baseId = useId()

  const state = useMemo(() => {
    function select(next: string) {
      if (next === selected) return
      if (!isControlled) setOwnValue(next)
      onValueChange?.(next)
    }
    return { selected, select, baseId }
  }, [selected, isControlled, onValueChange, baseId])

  return <TabsContext value={state}>{children}</TabsContext>
}

function TabList({ label, labelledBy, children }: { label?: string; labelledBy?: string; children: ReactNode }) {
  const listRef = useRef<HTMLDivElement>(null)

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (listRef.current === null) return
    const tabs = Array.from(listRef.current.querySelectorAll<HTMLElement>('[role="tab"]'))
    const now = tabs.findIndex((tab) => tab === e.target)
    const next = nextIndex(e.key, now, tabs.length)
    if (next === null) return
    e.preventDefault()
    tabs[next].focus()
  }

  return (
    <div role="tablist" aria-label={label} aria-labelledby={labelledBy} ref={listRef} onKeyDown={handleKeyDown}>
      {children}
    </div>
  )
}

function Tab({ value, children }: { value: string; children: ReactNode }) {
  const { selected, select, baseId } = useTabs()
  const isSelected = value === selected
  return (
    <button
      role="tab"
      id={`${baseId}-tab-${value}`}
      aria-controls={`${baseId}-panel-${value}`}
      aria-selected={isSelected}
      tabIndex={isSelected ? 0 : -1}
      style={{ fontWeight: isSelected ? 'bold' : 'normal' }}
      onClick={() => select(value)}
      onFocus={() => select(value)}
    >
      {children}
    </button>
  )
}

function TabPanel({ value, children }: { value: string; children: ReactNode }) {
  const { selected, baseId } = useTabs()
  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${value}`}
      aria-labelledby={`${baseId}-tab-${value}`}
      tabIndex={0}
      hidden={value !== selected}
    >
      {children}
    </div>
  )
}

Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel

export default function App() {
  const [tab, setTab] = useState('profile')
  return (
    <div>
      <p>The parent says: {tab}</p>
      <button onClick={() => setTab('billing')}>Go to Billing</button>
      <Tabs value={tab} onValueChange={setTab}>
        <Tabs.List label="My account">
          <Tabs.Tab value="profile">Profile</Tabs.Tab>
          <Tabs.Tab value="settings">Settings</Tabs.Tab>
          <Tabs.Tab value="billing">Billing</Tabs.Tab>
        </Tabs.List>
        <Tabs.Panel value="profile">
          <p>Name: Ana</p>
        </Tabs.Panel>
        <Tabs.Panel value="settings">
          <p>Email me: yes</p>
        </Tabs.Panel>
        <Tabs.Panel value="billing">
          <p>Plan: Free</p>
        </Tabs.Panel>
      </Tabs>
    </div>
  )
}

Run it. The line says “The parent says: profile”. Press “Go to Billing”. The Billing tab is selected, and its panel shows. The parent changed its state, and the tabs followed.

Now click the “Billing” tab and press the Right arrow. Focus wraps to “Profile”, and the line says “profile” again. The tabs called onValueChange, and the parent saved the new value.

One line is new for tabs: if (next === selected) return. With automatic activation, select runs on focus and on click. A tab can get both, one after the other. We tried that, with a console.log in the parent. In Chromium, Firefox and WebKit, a mouse click on a tab gave it focus and then a click. onValueChange ran once. Without that line, it ran twice. In jsdom, the parent then rendered again with the same value.

What if the parent ignores onValueChange? We tried a parent that never changes its state. The arrow keys still moved focus, but “Profile” stayed selected. In controlled mode, the parent decides.

One rule for both modes: something must be selected, and it must match a tab. This Tabs makes defaultValue an empty string when it is left out. Then no tab matches, and every tab gets tabIndex={-1}. We tried <Tabs> with no value and no defaultValue in Chromium, Firefox and WebKit. Three presses of Tab never reached the tabs. So always pass a defaultValue or a value that matches a tab. The first Tabs, in “Try this first”, makes TypeScript ask for defaultValue.

Hidden panels: keep them or remove them?

Our TabPanel keeps every panel on the page. It adds hidden to the ones that aren’t selected. Part 7 showed the other way: return null, and React removes the component. The choice changes what a panel remembers.

import { createContext, useContext, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'

function nextIndex(key: string, now: number, count: number) {
  if (key === 'ArrowRight') return (now + 1) % count
  if (key === 'ArrowLeft') return (now - 1 + count) % count
  if (key === 'Home') return 0
  if (key === 'End') return count - 1
  return null
}

type TabsState = {
  selected: string
  select: (value: string) => void
  baseId: string
}

const TabsContext = createContext<TabsState | null>(null)

function useTabs() {
  const state = useContext(TabsContext)
  if (state === null) {
    throw new Error('Tab parts must be inside <Tabs>')
  }
  return state
}

function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {
  const [selected, setSelected] = useState(defaultValue)
  const baseId = useId()
  const state = useMemo(() => ({ selected, select: setSelected, baseId }), [selected, baseId])
  return <TabsContext value={state}>{children}</TabsContext>
}

function TabList({ label, labelledBy, children }: { label?: string; labelledBy?: string; children: ReactNode }) {
  const listRef = useRef<HTMLDivElement>(null)

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (listRef.current === null) return
    const tabs = Array.from(listRef.current.querySelectorAll<HTMLElement>('[role="tab"]'))
    const now = tabs.findIndex((tab) => tab === e.target)
    const next = nextIndex(e.key, now, tabs.length)
    if (next === null) return
    e.preventDefault()
    tabs[next].focus()
  }

  return (
    <div role="tablist" aria-label={label} aria-labelledby={labelledBy} ref={listRef} onKeyDown={handleKeyDown}>
      {children}
    </div>
  )
}

function Tab({ value, children }: { value: string; children: ReactNode }) {
  const { selected, select, baseId } = useTabs()
  const isSelected = value === selected
  return (
    <button
      role="tab"
      id={`${baseId}-tab-${value}`}
      aria-controls={`${baseId}-panel-${value}`}
      aria-selected={isSelected}
      tabIndex={isSelected ? 0 : -1}
      style={{ fontWeight: isSelected ? 'bold' : 'normal' }}
      onClick={() => select(value)}
      onFocus={() => select(value)}
    >
      {children}
    </button>
  )
}

function TabPanel({ value, children }: { value: string; children: ReactNode }) {
  const { selected, baseId } = useTabs()
  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${value}`}
      aria-labelledby={`${baseId}-tab-${value}`}
      tabIndex={0}
      hidden={value !== selected}
    >
      {children}
    </div>
  )
}

Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel

function Likes() {
  const [likes, setLikes] = useState(0)
  return <button onClick={() => setLikes(likes + 1)}>Likes: {likes}</button>
}

export default function App() {
  return (
    <Tabs defaultValue="profile">
      <Tabs.List label="My account">
        <Tabs.Tab value="profile">Profile</Tabs.Tab>
        <Tabs.Tab value="settings">Settings</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel value="profile">
        <Likes />
      </Tabs.Panel>
      <Tabs.Panel value="settings">
        <p>Email me: yes</p>
      </Tabs.Panel>
    </Tabs>
  )
}

Click “Likes” three times. Switch to “Settings”, then back to “Profile”. It still says “Likes: 3”. While “Settings” was selected, the Profile panel stayed on the page, with hidden="" and “Likes: 3” inside.

Now press Edit and make TabPanel remove the panel. Add if (value !== selected) return null as its second line, and delete the hidden line. Do the same clicks. This time “Likes” goes back to 0. React removed Likes with its panel, and its state went with it.

We checked the aria-controls side too. With removed panels, the “Settings” and “Billing” tabs named ids that were not on the page. Part 38 kept closed panels on the page for the same reason. The guide’s own examples keep every panel on the page too. And for automatic activation, the panels must show without waiting. The guide notes that this usually means their content is loaded ahead of time.

Part 7 found a cost: a hidden component still renders when its parent renders. Does that happen here? We put a console.log in TabPanel and in Likes, and switched tabs. Each TabPanel rendered again, because it reads the context. But Likes rendered 0 times, with Strict Mode on and off. Likes came from App as part of children, the same element as before. Part 2 showed that React can skip it then.

A third way: <Activity>

React has a component for this, <Activity>. Part 7 mentioned it in an interview answer. Its mode is "visible" or "hidden". React’s docs say that when it is hidden, React hides its children with the CSS display: none. React “will also destroy their Effects”. React’s own page uses it for tabs.

We keep the <div hidden>, so every aria-controls still names a real panel. We put <Activity> inside it, around the panel’s children. Likes now has an Effect that logs, and a second tab shows a Clock that logs too:

import { Activity, createContext, useContext, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'

function nextIndex(key: string, now: number, count: number) {
  if (key === 'ArrowRight') return (now + 1) % count
  if (key === 'ArrowLeft') return (now - 1 + count) % count
  if (key === 'Home') return 0
  if (key === 'End') return count - 1
  return null
}

type TabsState = {
  selected: string
  select: (value: string) => void
  baseId: string
}

const TabsContext = createContext<TabsState | null>(null)

function useTabs() {
  const state = useContext(TabsContext)
  if (state === null) {
    throw new Error('Tab parts must be inside <Tabs>')
  }
  return state
}

function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {
  const [selected, setSelected] = useState(defaultValue)
  const baseId = useId()
  const state = useMemo(() => ({ selected, select: setSelected, baseId }), [selected, baseId])
  return <TabsContext value={state}>{children}</TabsContext>
}

function TabList({ label, labelledBy, children }: { label?: string; labelledBy?: string; children: ReactNode }) {
  const listRef = useRef<HTMLDivElement>(null)

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (listRef.current === null) return
    const tabs = Array.from(listRef.current.querySelectorAll<HTMLElement>('[role="tab"]'))
    const now = tabs.findIndex((tab) => tab === e.target)
    const next = nextIndex(e.key, now, tabs.length)
    if (next === null) return
    e.preventDefault()
    tabs[next].focus()
  }

  return (
    <div role="tablist" aria-label={label} aria-labelledby={labelledBy} ref={listRef} onKeyDown={handleKeyDown}>
      {children}
    </div>
  )
}

function Tab({ value, children }: { value: string; children: ReactNode }) {
  const { selected, select, baseId } = useTabs()
  const isSelected = value === selected
  return (
    <button
      role="tab"
      id={`${baseId}-tab-${value}`}
      aria-controls={`${baseId}-panel-${value}`}
      aria-selected={isSelected}
      tabIndex={isSelected ? 0 : -1}
      style={{ fontWeight: isSelected ? 'bold' : 'normal' }}
      onClick={() => select(value)}
      onFocus={() => select(value)}
    >
      {children}
    </button>
  )
}

function TabPanel({ value, children }: { value: string; children: ReactNode }) {
  const { selected, baseId } = useTabs()
  const isSelected = value === selected
  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${value}`}
      aria-labelledby={`${baseId}-tab-${value}`}
      tabIndex={0}
      hidden={!isSelected}
    >
      <Activity mode={isSelected ? 'visible' : 'hidden'}>{children}</Activity>
    </div>
  )
}

Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel

function Likes() {
  const [likes, setLikes] = useState(0)
  useEffect(() => {
    console.log('Likes Effect: set up')
    return () => console.log('Likes Effect: cleaned up')
  }, [])
  return <button onClick={() => setLikes(likes + 1)}>Likes: {likes}</button>
}

function Clock() {
  console.log('Clock renders')
  useEffect(() => {
    console.log('Clock Effect: set up')
    return () => console.log('Clock Effect: cleaned up')
  }, [])
  return <p>The clock is here.</p>
}

export default function App() {
  return (
    <Tabs defaultValue="profile">
      <Tabs.List label="My account">
        <Tabs.Tab value="profile">Profile</Tabs.Tab>
        <Tabs.Tab value="clock">Clock</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel value="profile">
        <Likes />
      </Tabs.Panel>
      <Tabs.Panel value="clock">
        <Clock />
      </Tabs.Panel>
    </Tabs>
  )
}

Here is the Console after those clicks, copied from a run in Chromium:

Likes Effect: set up
Likes Effect: cleaned up
Likes Effect: set up
Clock renders
Clock renders
Likes Effect: cleaned up
Clock Effect: set up
Clock Effect: cleaned up
Clock Effect: set up
Clock Effect: cleaned up
Likes Effect: set up
Likes Effect: cleaned up
Likes Effect: set up

Run it. Click “Likes” three times, then the “Clock” tab, then “Profile”. The count is still 3.

Read the Console. The first three lines are Strict Mode. In development, React runs each Effect’s setup, then its cleanup, then its setup again. Part 11 explained why. Then “Clock renders” shows twice, before you click anything. Twice is Strict Mode again: it renders each component twice. A hidden Activity still renders its children, so the Clock panel is ready before you open it. React’s docs call this pre-rendering. But its Effect doesn’t run yet.

When you click “Clock”, the Likes Effect is cleaned up, and the Clock Effect is set up. Strict Mode runs that new setup twice too. When you click “Profile”, it goes the other way. So the state is kept, but the Effects run only while the panel shows. The browsers agreed: Chromium, Firefox and WebKit printed the same lines.

Keep it, with hidden Remove it, with return null hidden, with <Activity> inside
State inside a hidden panel kept lost kept
Effects inside a hidden panel keep running cleaned up cleaned up, set up again when shown
Each tab’s aria-controls names a panel on the page can name an id that isn’t there names a panel on the page
Panels on the page all of them only the selected one all of them

Removing is still fine for a heavy panel, if it can start over each time. Activity fits a panel that should keep its state, but stop its Effects while no one looks. Practice 3 compares the first two ways.

Checking your tabs

Part 49 tests components with React Testing Library. Part 50 tests accessibility. Here is a first look.

Testing Library finds parts of the page by role, the way a screen reader does. On our tabs, queryAllByRole('tab') found “Profile”, “Settings” and “Billing”. With the role attributes taken out, it found nothing.

We also ran axe-core 4.14, a tool that checks a page for accessibility problems. On our tabs, it found none. Then we took out the roving tabIndex, so every tab was a Tab stop. It still found none. A tool can’t know how the keys should feel. So test by hand too. Put the mouse away, and use only Tab, the arrows, Home and End.

Common mistakes

Every tab is a Tab stop

A <button> is in the Tab order unless you say otherwise. Leave out tabIndex, and every tab is a stop. We deleted the tabIndex line from “Try this first”. The Tab key could then reach 4 things: three tabs and the panel. With the roving tabindex, it reached 2: the selected tab and its panel.

The fix: tabIndex={isSelected ? 0 : -1} on each tab.

Missing roles or ids

Without role="tab", a screen reader sees plain buttons. Part 46 covers roles in depth. We took the three role attributes out. Testing Library found no tabs. axe-core found a problem on all three buttons: “ARIA attribute is not allowed: aria-selected=”true””. A plain button can’t have aria-selected.

Ids need care too. aria-controls must name a real id. Make the ids with useId. Ids made from the value alone repeat when the component is used twice.

Matching by position

Numbers from render order break when a tab shows only sometimes, or moves. You saw both above. Match by value.

Nothing selected

With no defaultValue, or a value that matches no tab, every tab gets tabIndex={-1}. Then the Tab key can’t reach the row at all. We saw it in all three browsers. Always select a tab that exists.

Arrow keys that don’t wrap

function nextIndex(key: string, now: number, count: number) {
  if (key === 'ArrowRight') return now + 1
  if (key === 'ArrowLeft') return now - 1
  return null
}

Put focus on “Billing”, the last tab, and press the Right arrow. nextIndex gives 3, and tabs[3] is undefined. In Chromium, the key press throws this error:

Cannot read properties of undefined (reading 'focus')

Firefox and WebKit throw the same error in their own words. Focus stays on “Billing”.

The guide says the Right arrow on the last tab moves focus to the first. The fix is the % from before: (now + 1) % count and (now - 1 + count) % count.

Practice

Press Edit on “Try this first” for tasks 1, 2 and 4. Use the “Likes” example for task 3.

  1. Add a fourth tab, “Help”. Its panel says “Write to us any time”. What else must change so the arrow keys, Home and End reach it?
  2. Make the tab list go down the page. The guide says a list like that has aria-orientation="vertical". There, the Down arrow “performs as Right Arrow”. Change the code so the Down and Up arrows move focus.
  3. Add an Effect to Likes that logs when it mounts and unmounts: useEffect(() => { console.log('Likes mounted'); return () => console.log('Likes unmounted') }, []). Add useEffect to the import. Run it, then click “Settings” and then “Profile”. What does the Console show? Then make TabPanel remove panels, as above, and do the same.
  4. Make the tabs manual: delete the onFocus line from Tab. Click “Profile” and press the Right arrow twice. Which tab has focus? Which panel shows? How do you show the other panel?
Answers
  1. Add <Tabs.Tab value="help">Help</Tabs.Tab> after the Billing tab. After the Billing panel, add <Tabs.Panel value="help"><p>Write to us any time.</p></Tabs.Panel>. Nothing else changes. handleKeyDown asks the page for the tabs on each key press. We checked: the Right arrow went from “Billing” to “Help”, and from “Help” back to “Profile”. End went to “Help”, and Home to “Profile”.
  2. In nextIndex, change 'ArrowRight' to 'ArrowDown' and 'ArrowLeft' to 'ArrowUp'. In TabList, add aria-orientation="vertical" to the <div>. We checked. The Down arrow went from “Profile” to “Settings”. Then the Right arrow did nothing. The Up arrow went back to “Profile”, and once more to “Billing”. To make the tabs sit in a column, give the <div> a style, such as style={{ display: 'flex', flexDirection: 'column' }}.
  3. With hidden, the Console shows three lines when the page loads: “Likes mounted”, “Likes unmounted”, “Likes mounted”. That is Strict Mode. In development it runs each Effect’s setup, then its cleanup, then the setup again. Part 11 explained this. The clicks add nothing, because Likes stays on the page. With return null, the same three lines show first. “Settings” adds “Likes unmounted”. “Profile” adds three more: “Likes mounted”, “Likes unmounted”, “Likes mounted”. The new Likes goes through Strict Mode’s extra run too.
  4. Focus is on “Billing”, but the Profile panel still shows, and “Profile” is still selected. Press Space or Enter, or click “Billing”, to show its panel. In our test, the click selected it.

Interview questions

Try to answer each one out loud before you open the answer.

Which roles and ARIA attributes does an accessible tabs component need?

role="tablist" on the row, with a name from aria-label or aria-labelledby. role="tab" on each tab. aria-selected is true on the selected tab and false on the rest. aria-controls on each tab, naming its panel’s id. role="tabpanel" on each panel, with aria-labelledby naming its tab’s id. The panels that aren’t selected get hidden.

A strong answer names the source, the W3C’s ARIA Authoring Practices Guide. It uses aria-labelledby when a visible heading names the list, and aria-label when none does. It adds aria-orientation="vertical" for a list that goes down the page. It gives a panel tabindex="0" when the panel’s first element can’t take focus. And it says the ids come from useId, so two copies on one page don’t share ids. Part 46 covers ARIA, and Part 47 covers keyboard navigation.

What is a roving tabindex, and why do tabs use it?

Only one tab is in the Tab order at a time. It has tabIndex={0}, and the other tabs have -1. When the selection moves, the 0 moves with it. So the whole row is one Tab stop, and the arrow keys move inside the row. Without it, a keyboard user must press Tab once for each tab to get past the row.

A strong answer adds that -1 still lets code focus the element with focus(). That is how the arrow keys move focus. It may also name the guide’s other way, aria-activedescendant. There, focus stays on the container.

What is the difference between automatic and manual activation?

With automatic activation, a tab is selected as soon as it gets focus. So the arrow keys change the panel. With manual activation, the arrow keys only move focus. The user presses Space or Enter to select. The guide recommends automatic when panels show without waiting. Manual is better when showing a panel is slow, for example when it loads data.

A strong answer says how small the code difference is. In our component, it is one onFocus handler.

The tab list and the panels are in different places in the markup. How do they agree?

Through context. <Tabs> holds the selected value and gives it with a provider. The tabs and the panels each read it, and compare it with their own value. They don’t need to be near each other, or in any order. They only need to be inside the same <Tabs>.

Why match tabs to panels by value, not by index?

An index says where a part is, and that changes. Say parts take numbers in render order. Then a tab that shows only sometimes gets a number that is already used. In our test, two tabs were selected at once, and two panels showed. Moving tabs can split focus from the selection. A value says what the part is, and it stays the same.

A strong answer adds that numbering parts in render order means reading and writing refs while rendering. That breaks React’s rules. It may also mention that the values must be unique.

Should the panels that aren’t selected stay on the page?

It depends. With hidden, they keep their state and Effects, and every aria-controls names a real element. The guide’s examples do this. With return null, they are removed, so their state is lost and their Effects are cleaned up. That can be right for a heavy panel that should start over each time. A third way keeps the <div hidden> and wraps the children in <Activity>. The state is kept. The Effects are cleaned up while the panel is hidden, and set up again when it shows.

A strong answer mentions automatic activation. Panels must show without waiting, and keeping them on the page helps.

How do you move focus to the next tab when an arrow key is pressed?

Put one onKeyDown on the tab list. Key events bubble up from the tabs. Keep a ref to the list’s element. In the handler, find the tabs with querySelectorAll('[role="tab"]'). Find the one that got the key, and work out the next one. Call focus() on it. Use % so the last tab wraps to the first. Call preventDefault() for the keys you handle.

A strong answer explains why it asks the page each time. The page always has the right order, even after tabs are added, removed or moved.

Sources

  • W3C ARIA Authoring Practices Guide, Tabs Pattern: the words tab list, tab and tab panel, the keys (Tab, the arrows, Home, End, Space, Enter), automatic activation “as long as” panels show without waiting, “preloaded”, the roles and attributes, aria-labelledby for a visible label, and when a panel needs tabindex="0".
  • W3C ARIA Authoring Practices Guide, Tabs with Automatic Activation and Tabs with Manual Activation: tabindex="-1" on the tabs that aren’t selected, tabindex="0" on the panel and why, and panels kept on the page.
  • W3C ARIA Authoring Practices Guide, Developing a Keyboard Interface: the roving tabindex steps, and when selection should follow focus.
  • MDN: tabindex (“not reachable via sequential keyboard navigation”), tab role (hidden on the other panels), and button role (Space and Enter click a <button>).
  • useId, react.dev: unique ids for accessibility attributes.
  • Activity, react.dev: hiding children with display: none, destroying their Effects while hidden, keeping state, pre-rendering, and the tabs example.
  • Radix UI, Tabs: the props value, defaultValue, onValueChange and activationMode.
  • useRef and Manipulating the DOM with Refs, react.dev: calling focus() through a ref, and not reading or writing refs while rendering.
  • The HTML, the focus and selection after each key, the ids, the Tab stops, the counts and the error text come from running React 19.3.0 in jsdom for this post. The checks used React Testing Library’s DOM queries 10.4.2 and axe-core 4.14.0. The keyboard results, the error text and the onValueChange counts in browsers come from Chromium 151, Firefox 153 and WebKit 26.5, through playwright-core 1.62.1, with the same code inside <StrictMode>.
  • This part follows the Compound Components: Tabs kata in react-katas. We changed four things in the kata. Our parts match by value, because numbers kept in refs don’t shift, and a new tab reuses a number. We keep hidden panels on the page, because the kata removes them, and then aria-controls can name an id that isn’t there. Our tab list has a name, and the kata’s has none. And the props are value, defaultValue and onValueChange, not index, defaultIndex and onChange.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.