Build accessible tabs
easyAccessibility+150 XP
Build a tab list with three tabs, each showing its own panel.
Clicking a tab activates it. The keyboard has to work too: with a tab active, ArrowRight/ArrowLeft move to the next/previous tab and activate it immediately, wrapping around at each end.
Examples
// Initial: [Overview]* Specs Reviews
// tabpanel renders "Overview content".
// Click Specs → aria-selected moves to Specs; panel = "Specs content".
// Focus Reviews, press ArrowRight
// → wraps to Overview.
// Focus Overview, press ArrowLeft
// → wraps to Reviews.Automatic activation — arrow keys don't just move focus, they commit the tab.
function handleKeyDown(e) {
if (e.key === "ArrowRight") {
setActive((i) => (i + 1) % TABS.length);
} else if (e.key === "ArrowLeft") {
setActive((i) => (i - 1 + TABS.length) % TABS.length);
}
}`(i - 1 + N) % N` handles the negative wrap safely; plain `-1 % N` goes to -1 in JS.
What it has to do
- Each tab is a `<button role="tab" aria-selected="true|false">`, and there are exactly three.
- Exactly one `<div role="tabpanel">` is present at a time — the one for the active tab.
- Clicking a tab makes it the active one.
- With the last tab active, ArrowRight moves to the first tab. With the first tab active, ArrowLeft moves to the last.
Notes
- ARIA has two patterns: automatic activation (arrows change tab) and manual activation (arrows move focus, Enter activates). This problem uses automatic — common for small tab sets.
- Link each tab to its panel: `aria-controls="panel-X"` on the tab, `aria-labelledby="tab-X"` on the panel. The test doesn't check these, but production needs them.
- Use roving tabindex (`tabindex=0` on the active tab, `-1` on the rest) so Tab moves users past the whole tab group — not through each tab individually.
- Keep only one `<div role="tabpanel">` mounted — unmounting on switch resets any transient panel state (scroll position, form drafts). That's a feature, not a bug.
Stuck? The react course covers the ideas this problem is built on.
import { useState } from "react"; const TABS = [ { key: "overview", label: "Overview", content: "Overview content" }, { key: "specs", label: "Specs", content: "Specs content" }, { key: "reviews", label: "Reviews", content: "Reviews content" }, ]; export default function App() { const [active, setActive] = useState(0); function handleKeyDown(e) { // TODO: ArrowRight should move to the next tab (wrapping to the // first after the last), ArrowLeft to the previous (wrapping to // the last from the first) — and the newly reached tab should // become active, same as clicking it. } return ( <div style={{ fontFamily: "sans-serif", padding: 16 }}> <div role="tablist" style={{ display: "flex", gap: 8 }}> {TABS.map((tab, index) => ( <button key={tab.key} id={`tab-${tab.key}`} role="tab" aria-selected={index === active} onClick={() => setActive(index)} onKeyDown={handleKeyDown} style={{ padding: "8px 12px", fontWeight: index === active ? "bold" : "normal" }} > {tab.label} </button> ))} </div> <div id={`panel-${TABS[active].key}`} role="tabpanel" style={{ padding: 16, border: "1px solid #ddd" }}> {TABS[active].content} </div> </div> ); }
- 1.Each tab is a `<button role="tab" aria-selected="true|false">`, and there are exactly three.
- 2.Exactly one `<div role="tabpanel">` is present at a time — the one for the active tab.
- 3.Clicking a tab makes it the active one.
- 4.With the last tab active, ArrowRight moves to the first tab. With the first tab active, ArrowLeft moves to the last.
5 automated assertions run against your rendered component. The exact assertion names are hidden so you build to the behaviour, not to the test.
5 tests · +150 XP
Discussion
Sign in to postSign in to join the conversation.Sign in
No comments yet. Be the first to say something.