Skip to content

UI build: accessible tabs and accordion

Tabs are one stop with arrows inside; an accordion is a row of stops. Wire both ways, and step over what is disabled.

Predict first

Before you read: predict

ExamHall's exam lobby renders <LobbyTabs tabs={lobbyTabs} /> with three tabs: Instructions, System check and Rules. System check has disabled: true until the camera test has loaded, and it has not loaded yet.

A candidate presses Tab into the tabs, so focus is on Instructions, and then presses Right once.

Which tab has focus, and which panel shows?

Code for this question
function LobbyTabs({ tabs }) {  const [selected, setSelected] = useState(0);  const refs = useRef([]);   function onKeyDown(e) {    const last = tabs.length - 1;    let next;    if (e.key === 'ArrowRight') next = selected === last ? 0 : selected + 1;    else if (e.key === 'ArrowLeft') next = selected === 0 ? last : selected - 1;    else if (e.key === 'Home') next = 0;    else if (e.key === 'End') next = last;    else return;    e.preventDefault();    setSelected(next);    refs.current[next].focus();  }   return (    <>      <div role="tablist" aria-label="Exam lobby">        {tabs.map((t, i) => (          <button key={t.id} ref={(el) => { refs.current[i] = el; }} role="tab"                  id={`tab-${t.id}`} aria-selected={i === selected}                  aria-controls={`panel-${t.id}`} tabIndex={i === selected ? 0 : -1}                  disabled={t.disabled} onClick={() => setSelected(i)} onKeyDown={onKeyDown}>            {t.label}          </button>        ))}      </div>      {tabs.map((t, i) => (        <div key={t.id} role="tabpanel" id={`panel-${t.id}`} aria-labelledby={`tab-${t.id}`}             tabIndex={0} hidden={i !== selected}>          {t.body}        </div>      ))}    </>  );}
Pick one

The 30-second version

Tabs are one Tab stop: Tab enters on the selected tab, arrows move between tabs and wrap, Home and End jump, and the next Tab goes to the panel. Each tab names its panel with aria-controls, each panel names its tab with aria-labelledby. An accordion is the opposite: every header is its own stop and carries aria-expanded.

Tabs are one stop with arrows inside; an accordion is a row of stops. Wire both ways, and step over what is disabled.

How it works

Step 1 of 14

Roles. A tablist holds tab buttons; each panel is a tabpanel. The active tab has aria-selected="true"; only its panel shows.
In the kitchen
  • only the objective in the light path gives the image → one panel shows (step 1)

Analogy: A teaching microscope's objective turret (microscopy)

A student at a teaching microscope changes magnification.

At the microscope In the build
her hand finds the turret once, then clicks it round one Tab stop, arrows inside (step 3)
past the highest power, it clicks round to the lowest arrows wrap (step 4)
the lowest and highest are marked on the ring Home and End (step 5)
a port whose objective is out for repair must be clicked past step over a disabled tab (step 7)
only the objective in the light path gives the image one panel shows (step 1)
the barrel's engraving names the image she sees aria-labelledby and aria-controls (step 2)
a slow motorised turret waits for "go" manual activation (step 8)

In real terms: tabs are one stop, a handler for four keys that wraps and skips, and two id links per tab.

Breaks down when: a real turret's empty port still clicks into the light path; a tab list skips a disabled tab only if your handler does.

Contrasting case: a slide box with hinged lids. Each lid is its own reach, and several can stand open, or the lids are linked so one opening closes the other: an accordion, multiple or single (steps 11–13).

Examples, run for real

4.1 ExamHall EXH-3610 and MR !3910: the kata's reference in the exam lobby

EXH-3610 is the lobby's three tabs, built as divs with click handlers only: the keyboard could not reach Rules. MR !3910 replaces them with the kata's reference, with System check enabled once the camera test loads.

4.1 before (EXH-3610, div tabs with click handlers): Tab from Help: focus on Start exam · tabs a keyboard can reach: 0 of 3
4.1 MR !3910: Tab from Help: focus on the Instructions tab · Tab again: focus on the Instructions panel
4.1 Right, Right, Right: System check > Rules > Instructions · Left: Rules
4.1 Home: Instructions · End: Rules · snapshot - tabpanel "Rules": No notes and no second screen. · axe violations 0

Focus enters on the selected tab and the next Tab reaches its panel, never the next tab (step 3). Right from Rules comes round to Instructions, Left from Instructions goes back to Rules, and Home and End jump (steps 4–5). Each panel is named by its tab (step 2).

4.2 Ticketing: a box office FAQ accordion in multiple mode

The box office FAQ has three headers, Refunds, Transfers and Accessibility, each a button inside an h3.

4.2 at load: no header expanded · Enter on Refunds, then Enter on Transfers: expanded Refunds and Transfers
4.2 snapshot - button "Refunds" [expanded] · axe violations 0

Each header toggles only its own panel, so two stay open together (step 13), and the tree reports [expanded] on the button (step 12). axe finds no violation.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

A box office's seat picker shows four sections as tabs: Stalls, Circle, Upper circle and Boxes. Stalls is selected and its seats have loaded.

With focus on Stalls, a fan presses Left twice to reach Upper circle, then Enter. The seat server answers every request at once.

How many seat requests do those three key presses send?

Code for this question
function SeatPanel({ section }) {  const [seats, setSeats] = useState(null);  useEffect(() => {    fetch(`/api/seats?section=${section.id}`).then((r) => r.json()).then(setSeats);  }, [section.id]);  return seats ? <p>{seats.free} seats free</p> : <p>Loading…</p>;} function SectionTabs({ sections }) {  const [selected, setSelected] = useState(0);  const refs = useRef([]);   function onKeyDown(e) {    const n = sections.length;    let next;    if (e.key === 'ArrowRight') next = (selected + 1) % n;    else if (e.key === 'ArrowLeft') next = (selected - 1 + n) % n;    else return;    e.preventDefault();    setSelected(next);    refs.current[next].focus();  }   const s = sections[selected];  return (    <>      <div role="tablist" aria-label="Sections">        {sections.map((t, i) => (          <button key={t.id} ref={(el) => { refs.current[i] = el; }} role="tab"                  id={`tab-${t.id}`} aria-selected={i === selected} aria-controls="seat-panel"                  tabIndex={i === selected ? 0 : -1}                  onClick={() => setSelected(i)} onKeyDown={onKeyDown}>            {t.name}          </button>        ))}      </div>      <div role="tabpanel" id="seat-panel" aria-labelledby={`tab-${s.id}`} tabIndex={0}>        <SeatPanel key={s.id} section={s} />      </div>    </>  );}
Pick one

Question 2 of 8

MR !3911 adds settings tabs to a chat app: Profile, Notifications and Privacy. The page renders <SettingsTabs tabs={settings} />. The MR adds:

Three review comments were posted.

Which one names a real defect?

Code for this question
function SettingsTabs({ tabs }) {  const [selected, setSelected] = useState(0);  const refs = useRef([]);   function onKeyDown(e) {    const n = tabs.length;    let next;    if (e.key === 'ArrowRight') next = (selected + 1) % n;    else if (e.key === 'ArrowLeft') next = (selected - 1 + n) % n;    else return;    e.preventDefault();    setSelected(next);    refs.current[next].focus();  }   return (    <>      <div role="tablist" aria-label="Settings">        {tabs.map((t, i) => (          <button key={t.id} ref={(el) => { refs.current[i] = el; }} role="tab"                  id={`tab-${t.id}`} aria-selected={i === selected}                  aria-controls={`${t.id}-panel`} tabIndex={i === selected ? 0 : -1}                  onClick={() => setSelected(i)} onKeyDown={onKeyDown}>            {t.label}          </button>        ))}      </div>      {tabs.map((t, i) => (        <div key={t.id} role="tabpanel" id={`panel-${t.id}`} aria-labelledby={`tab-${t.id}`}             tabIndex={0} hidden={i !== selected}>          {t.body}        </div>      ))}    </>  );}
Pick one

Question 3 of 8

A streaming service's help page has an accordion with three headers, Buffering, Captions and Billing, followed by a Contact support button. Buffering is open; Captions and Billing are closed. Each panel holds a link, the closed ones too: Run a speed test under Buffering, Caption settings under Captions, Billing history under Billing.

Focus is on the Billing header, and the viewer presses Shift+Tab once.

Which element has focus now?

Code for this question
function HelpAccordion({ sections }) {  const [open, setOpen] = useState(['buffering']);  const toggle = (id) =>    setOpen(open.includes(id) ? open.filter((x) => x !== id) : [...open, id]);   return sections.map((s) => (    <div key={s.id}>      <h3>        <button id={`head-${s.id}`} aria-expanded={open.includes(s.id)}                aria-controls={`body-${s.id}`} onClick={() => toggle(s.id)}>          {s.title}        </button>      </h3>      <div id={`body-${s.id}`} role="region" aria-labelledby={`head-${s.id}`}           hidden={!open.includes(s.id)}>        {s.body}      </div>    </div>  ));}
Pick one

Question 4 of 8

A payments dashboard has four tabs: Ledger, Payouts, Refunds and Disputes. Disputes has disabled: true for this merchant. The team added one line to step over disabled tabs:

The rest of the component renders the tabs and panels as the APG tabs pattern does, with disabled={t.disabled} on each tab. With focus on Payouts, which is selected, a clerk presses End.

Which tab has focus, and which panel shows?

Code for this question
function onKeyDown(e) {  const n = tabs.length;  let next;  if (e.key === 'ArrowRight') next = (selected + 1) % n;  else if (e.key === 'ArrowLeft') next = (selected - 1 + n) % n;  else if (e.key === 'Home') next = 0;  else if (e.key === 'End') next = n - 1;  else return;  e.preventDefault();  while (tabs[next].disabled) next = (next + 1) % n;  setSelected(next);  refs.current[next].focus();}
Pick one

Question 5 of 8

the interviewer says: "Build the exam lobby's tabs: Instructions, System check, Rules. LobbyTabs({ tabs }) gets { id, label, body, disabled } per tab; System check stays disabled until the camera test loads. Automatic activation." You have one coding round, a plain editor and React 19.

Before you type: say your keyboard test list out loud.

Name at least eight tests, each with its keys and what it expects.

Question 6 of 8

the interviewer says: "Build the exam lobby's tabs: Instructions, System check, Rules. LobbyTabs({ tabs }) gets { id, label, body, disabled } per tab; System check stays disabled until the camera test loads. Automatic activation." You have one coding round, a plain editor and React 19.

Now write it, from a blank file that defines LobbyTabs.

Question 7 of 8

A chat app's settings page lists three sections as an accordion: Notifications, Privacy and Storage. Notifications is open when the page loads.

A user moves focus to the Privacy header and presses Enter twice.

Which headers report aria-expanded="true" now?

Code for this question
function SettingsAccordion({ sections }) {  const [open, setOpen] = useState('notifications');   return sections.map((s) => (    <div key={s.id}>      <h3>        <button aria-expanded={open === s.id} aria-controls={`sec-${s.id}`}                onClick={() => setOpen(open === s.id ? null : s.id)}>          {s.title}        </button>      </h3>      <div id={`sec-${s.id}`} hidden={open !== s.id}>{s.body}</div>    </div>  ));}
Pick one

Question 8 of 8

A festival's booking page has three tabs, Dates, Seats and Extras, followed by their panels; Dates is selected. Its tabs use manual activation:

With focus on Dates, a fan presses Right once, then Tab.

Which element has focus now?

Code for this question
function BookingTabs({ tabs }) {  const [selected, setSelected] = useState(0);  const refs = useRef([]);   function onKeyDown(e, i) {    const n = tabs.length;    let next;    if (e.key === 'ArrowRight') next = (i + 1) % n;    else if (e.key === 'ArrowLeft') next = (i - 1 + n) % n;    else return;    e.preventDefault();    refs.current[next].focus();  }   return (    <>      <div role="tablist" aria-label="Booking">        {tabs.map((t, i) => (          <button key={t.id} ref={(el) => { refs.current[i] = el; }} role="tab"                  id={`tab-${t.id}`} aria-selected={i === selected}                  aria-controls={`panel-${t.id}`} tabIndex={i === selected ? 0 : -1}                  onClick={() => setSelected(i)} onKeyDown={(e) => onKeyDown(e, i)}>            {t.label}          </button>        ))}      </div>      {tabs.map((t, i) => (        <div key={t.id} role="tabpanel" id={`panel-${t.id}`} aria-labelledby={`tab-${t.id}`}             tabIndex={0} hidden={i !== selected}>          {t.body}        </div>      ))}    </>  );}
Pick one

Go deeper

Pro tips
  1. Say "roving tabindex" and demo Home and End as well as the arrows. When: the interviewer asks how a keyboard user moves through your tabs (steps 3–5). Verify (RAN, tips.mjs): the reference, with System check enabled, focus on Instructions:
    tip 1 from Instructions: Right > System check · End > Rules · Right > Instructions · Left > Rules · Home > Instructions
    (The curriculum row's tip, [DOC]: the APG tabs pattern lists all four keys.)
  2. Run your own build against the keyboard list before you call it done: KATA_IMPL=./my-tabs.jsx node --test kata/kata.test.mjs. When: practising the kata at home, against the cases the reference passes (step 14). Verify (RAN, tips.mjs):
    tip 2 KATA_IMPL=./kata/reference.jsx: exit 0 · tests 12 · pass 12 · fail 0
    tip 2 KATA_IMPL=<temp>/no-skip.jsx (move() lands on disabled tabs): exit 1 · tests 12 · pass 8 · fail 4 · red: K3, K4, K7, K8
Source

Guides change, so their claims are labelled as of 2026-10-12; every behaviour this lesson grades is executed in Chromium.

  • W3C WAI ARIA Authoring Practices Guide, Tabs Pattern (the curriculum row's first source), https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ (apg-tabs.txt): "When focus moves into the tab list, places focus on the active tab element."; "If focus is on the last tab element, moves focus to the first tab."; "Home (Optional): Moves focus to the first tab."; "Each element with role tab has the property aria-controls referring to its associated tabpanel element."; "Each element with role tabpanel has the property aria-labelledby referring to its associated tab element."; "It is recommended that tabs activate automatically when they receive focus as long as their associated tab panels are displayed without noticeable latency."; "the tabpanel should set tabindex="0" to include it in the tab sequence of the page" (steps 1–5, 8). As of 2026-10-12.
  • W3C WAI ARIA Authoring Practices Guide, Example of Tabs with Manual Activation, https://www.w3.org/WAI/ARIA/apg/patterns/tabs/examples/tabs-manual/ (apg-tabs-manual.txt): "Set when a tab is not selected so that only the selected (active) tab is in the page Tab sequence." (step 10). As of 2026-10-12.
  • W3C WAI ARIA Authoring Practices Guide, Accordion Pattern (the row's second source), https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ (apg-accordion.txt): "all focusable elements in the accordion are included in the page Tab sequence."; "If the implementation allows only one panel to be expanded, and if another panel is expanded, collapses that panel."; "If the accordion panel associated with an accordion header is visible, the header button element has aria-expanded set to true."; "if the accordion does not permit the panel to be collapsed, the header button element has aria-disabled set to true." (steps 11–13). As of 2026-10-12.
  • W3C, Web Content Accessibility Guidelines (WCAG) 2.2 (the row's third source), https://www.w3.org/TR/WCAG22/ (wcag22.txt): "All functionality of the content is operable through a keyboard interface" (Success Criterion 2.1.1, the requirement the lobby's EXH-3610 broke). As of 2026-10-12. Which services the European Accessibility Act covers belongs to ui-wcag22-eaa.

APG names no disabled tab; step 7 (a script's focus() on a disabled button does nothing, and the handler must step over it) is executed, not cited.

Checked against the tools and docs as of 2026-10-12.