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?
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> ))} </> );}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
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?
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> </> );}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?
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> ))} </> );}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?
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> ));}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?
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();}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?
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> ));}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?
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> ))} </> );}Go deeper
Pro tips
- 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:
(The curriculum row's tip, [DOC]: the APG tabs pattern lists all four keys.)tip 1 from Instructions: Right > System check · End > Rules · Right > Instructions · Left > Rules · Home > Instructions - 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 toui-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.
Related lessons
- Read first: Focus and keyboard management
- Read first: Semantic HTML, the accessibility tree and ARIA
- Related: WCAG 2.2 AA and the European Accessibility Act
- Related: Effects as synchronization
- Related: Reconciliation, identity and keys
- Related: UI build: typeahead with debounce and race handling
- Related: UI build: accessible modal with a focus trap
Checked against the tools and docs as of 2026-10-12.