Build a Tabs
State = the active tab id. Render a tablist of buttons and the active panel. The grading is accessibility: role=tablist/tab/tabpanel, aria-selected, roving tabindex, and arrow-key navigation between tabs. Often built as a compound component (Tabs/Tab/TabPanel) sharing state via Context.
A Tabs component is simple state-wise — activeTab — but interviewers grade the accessibility and the API design.
Minimal implementation
function Tabs({ tabs }) { // tabs: [{ id, label, content }]
const [active, setActive] = useState(tabs[0].id);
const onKeyDown = (e, idx) => {
if (e.key === "ArrowRight") setActive(tabs[(idx + 1) % tabs.length].id);
if (e.key === "ArrowLeft") setActive(tabs[(idx - 1 + tabs.length) % tabs.length].id);
};
return (
<div>
<div role="tablist">
{tabs.map((tab, idx) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={active === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={active === tab.id ? 0 : -1} {/* roving tabindex */}
onClick={() => setActive(tab.id)}
onKeyDown={(e) => onKeyDown(e, idx)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={active !== tab.id}
>
{tab.content}
</div>
))}
</div>
);
}What's actually being graded — accessibility
This is a WAI-ARIA pattern; getting it right is the point:
role="tablist",role="tab"on each button,role="tabpanel"on each panel.aria-selectedon the active tab;aria-controlslinks tab → panel;aria-labelledbylinks panel → tab.- Roving tabindex — only the active tab is
tabIndex={0}; the rest are-1. So Tab moves into and out of the tablist as one stop; arrow keys move between tabs. - Arrow-key navigation — Left/Right (Up/Down for vertical) cycle tabs; Home/End jump to first/last.
- Use real
<button>s so Enter/Space work for free.
API design — compound component
For a reusable library version, build it as a compound component sharing state via Context:
<Tabs defaultValue="a">
<TabList>
<Tab value="a">First</Tab>
<Tab value="b">Second</Tab>
</TabList>
<TabPanel value="a">...</TabPanel>
<TabPanel value="b">...</TabPanel>
</Tabs>Tabs holds active in Context; Tab/TabPanel read it. More flexible than a tabs prop array.
Other considerations
- Controlled vs uncontrolled — support both (
value/onChangeordefaultValue). - Lazy panels — optionally only mount the active panel's content.
- Sync active tab to the URL for deep-linking.
The framing
"State is just activeTab. What's graded is the WAI-ARIA tabs pattern: role=tablist/tab/tabpanel, aria-selected, aria-controls/aria-labelledby wiring tabs to panels, and crucially a roving tabindex so the tablist is one Tab-stop and arrow keys move between tabs. Real <button>s give Enter/Space for free. For a library API I'd build it as a compound component — Tabs/TabList/Tab/TabPanel sharing state via Context — and support controlled and uncontrolled modes."
Follow-up questions
- •What is roving tabindex and why is it needed here?
- •Which ARIA roles and attributes does the tabs pattern require?
- •How would you design this as a compound component?
- •How do you support both controlled and uncontrolled usage?
Common mistakes
- •Divs with onClick instead of <button>s — no keyboard support.
- •Missing roving tabindex, so all tabs are tab stops.
- •No arrow-key navigation between tabs.
- •Missing aria-selected / aria-controls wiring.
- •Rendering all panels visible or all mounted with no hidden attribute.
Performance considerations
- •Trivial. Lazy-mounting only the active panel's content avoids rendering expensive hidden panels; keeping panels mounted but hidden preserves their state — a deliberate trade-off.
Edge cases
- •Wrapping from last tab to first with arrow keys.
- •A disabled tab — skip it in keyboard nav.
- •Many tabs overflowing — scroll or overflow menu.
- •Vertical tabs (Up/Down instead of Left/Right).
- •Deep-linking to a specific tab.
Real-world examples
- •Settings pages, product detail sections, dashboard views.
- •Radix UI / Headless UI Tabs implementing exactly this ARIA pattern.