Build an accessible Tabs component
Compound components: <Tabs><TabList><Tab/></TabList><TabPanels><TabPanel/></TabPanels></Tabs>. Active tab in context. Keyboard: Left/Right arrows move focus, Home/End jump. Roles: tablist, tab, tabpanel with aria-controls / aria-labelledby pairing.
Tabs look trivial — until you list the WAI-ARIA Tabs pattern requirements. The interview signal is whether you reach for the right roles, attributes, and keyboard model from the start.
API shape — compound components.
<Tabs defaultIndex={0}>
<TabList>
<Tab>Overview</Tab>
<Tab>Reviews</Tab>
<Tab disabled>Admin</Tab>
</TabList>
<TabPanels>
<TabPanel>...</TabPanel>
<TabPanel>...</TabPanel>
<TabPanel>...</TabPanel>
</TabPanels>
</Tabs>Compound components share state via context — Tabs provides { activeIndex, setActive }, children consume. Cleaner than a single component with tabs={[…]} prop because consumers control the rendered JSX (icons, badges, conditional tabs).
Implementation skeleton.
const TabsCtx = createContext<{ active: number; setActive: (i: number) => void; baseId: string } | null>(null);
const useTabs = () => { const c = useContext(TabsCtx); if (!c) throw new Error("Tabs ctx"); return c; };
export function Tabs({ defaultIndex = 0, children }: { defaultIndex?: number; children: React.ReactNode }) {
const [active, setActive] = useState(defaultIndex);
const baseId = useId();
return <TabsCtx.Provider value={{ active, setActive, baseId }}>{children}</TabsCtx.Provider>;
}
export function TabList({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement | null>(null);
function onKeyDown(e: React.KeyboardEvent) {
const tabs = ref.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])');
if (!tabs?.length) return;
const current = document.activeElement as HTMLElement;
const idx = Array.from(tabs).indexOf(current as HTMLButtonElement);
let next = idx;
if (e.key === "ArrowRight") next = (idx + 1) % tabs.length;
else if (e.key === "ArrowLeft") next = (idx - 1 + tabs.length) % tabs.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
else return;
e.preventDefault();
tabs[next].focus();
}
return <div role="tablist" ref={ref} onKeyDown={onKeyDown}>{children}</div>;
}
let tabIndex = 0;
export function Tab({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) {
const { active, setActive, baseId } = useTabs();
const i = useMemo(() => tabIndex++, []); // simple positional id; better: use Children.map at parent
const selected = i === active;
return (
<button
role="tab"
id={`${baseId}-tab-${i}`}
aria-controls={`${baseId}-panel-${i}`}
aria-selected={selected}
tabIndex={selected ? 0 : -1}
disabled={disabled}
onClick={() => setActive(i)}
>
{children}
</button>
);
}(In production, derive indices via React.Children.map in TabList/TabPanels parents to avoid module-scoped counters.)
ARIA roles and attributes (the must-haves).
role="tablist"on the container.role="tab"on each button.aria-selected="true"on the active one.aria-controlspointing to the panel id.role="tabpanel"on each panel.aria-labelledbypointing back to the tab id.tabindex="0"on the active tab;tabindex="-1"on the others. This is the roving tabindex pattern — Tab moves to the active item, then arrow keys move within.
Keyboard model.
- ArrowRight / ArrowLeft (or Down/Up for vertical tabs) — move focus and (optionally) select.
- Home / End — first / last tab.
- Tab — leave the tablist into the panel.
- Enter / Space — activate (if you decoupled focus from selection).
Focus-on-arrow vs activate-on-arrow. Two flavors:
- Activate on focus (default WAI-ARIA): pressing Right both moves focus AND switches tab. Lower friction.
- Manual activation: arrow moves focus, Enter/Space activates. Right when activation triggers expensive work (data fetch, route change).
Implement manual activation by tracking a separate "focused index" and only calling setActive on Enter/Space.
Lazy panels. Don't mount inactive panels until first activation:
{active === i && <TabPanel>{...}</TabPanel>}Trade-off: state inside inactive panels is lost. If you want preserved state, render them with hidden (CSS display:none semantically, but keeps the React tree mounted).
Animation / transitions. Slide content in/out with framer-motion's AnimatePresence. Don't unmount mid-animation.
Common mistakes.
- Using
<a href>for tabs that don't navigate — confuses assistive tech. - Missing
aria-controls/aria-labelledbylinking tab to panel. - Setting
tabindex="0"on every tab — Tab navigation fights with Arrow navigation. - Removing the focus ring "for design" — keyboard users can't see where focus is.
Use a library. Radix Tabs, Headless UI Tabs, react-aria's useTabs — all production-grade. Roll your own only when you need an unusual API.
Code
Follow-up questions
- •What's the roving tabindex pattern?
- •When would you use manual activation over activate-on-focus?
- •How do you preserve state in an inactive tab panel?
- •Why use compound components over a tabs={[…]} prop?
Common mistakes
- •Missing aria-controls / aria-labelledby — screen readers can't link tab to panel.
- •All tabs with tabindex=0 — Tab key visits every tab instead of one.
- •Mounting all panels by default — heavy for charts/lists.
- •Switching tabs via onMouseDown — keyboard users can't activate.
Performance considerations
- •Lazy-mount inactive panels for heavy content.
- •Memoize the active panel's children to avoid re-renders on tab switch.
Edge cases
- •Disabled tab in the middle — arrow nav must skip it.
- •Vertical tablist — change ArrowLeft/Right to ArrowUp/Down.
- •Dynamic tabs (add/remove) — keep activeIndex in sync; clamp on remove.
Real-world examples
- •Stripe Dashboard tabs, Linear's settings tabs, GitHub repo nav (Code/Issues/Pull requests).