Tabs Component
Machine-coding brief for accessible tabs — tablist pattern, keyboard arrows, panels, and controlled state.
intermediate4 min read
- machine-coding
- interview
- react
- a11y
Problem statement
Build Tabs: a tab list selects which panel is visible. This is an APG pattern interview favorite — wrong roles or missing arrow-key navigation fails the a11y bar even if the UI “looks right”.
Requirements
Must have
- N tabs with associated panels
- Only one active tab/panel
- Click selects tab
- Keyboard: Left/Right (or Up/Down for vertical) move; Home/End; optional automatic activation vs manual (Enter/Space)
- Correct roles:
tablist,tab,tabpanel aria-selected,aria-controls,idwiring
Should have
- Controlled + uncontrolled modes
- Disabled tabs
- Vertical orientation prop
Nice to have
- Lazy mount panels (keep state once mounted)
- Overflow scroll tabs with fade
- Route-synced tabs
Planning (5 minutes out loud)
- Automatic vs manual activation — automatic selects on focus (common); manual needs Enter
- Roving tabindex on tabs
- Ids via
useId - MVP — click + roles; then arrows
- Don’t use only CSS radio hacks without semantics
Architecture
Tabs
├── TabList
│ └── Tab
└── TabPanel[]
API
type TabItem = {
id: string;
label: string;
content: React.ReactNode;
disabled?: boolean;
};
type TabsProps = {
items: TabItem[];
value?: string;
defaultValue?: string;
onChange?: (id: string) => void;
orientation?: "horizontal" | "vertical";
activation?: "automatic" | "manual";
};
Implementation sketch
function Tabs({
items,
value: controlled,
defaultValue,
onChange,
orientation = "horizontal",
activation = "automatic",
}: TabsProps) {
const [uncontrolled, setUncontrolled] = useState(
defaultValue ?? items.find((i) => !i.disabled)?.id ?? items[0]?.id
);
const value = controlled ?? uncontrolled;
const baseId = useId();
function select(id: string) {
if (controlled === undefined) setUncontrolled(id);
onChange?.(id);
}
function move(delta: number) {
const enabled = items.filter((i) => !i.disabled);
const idx = enabled.findIndex((i) => i.id === value);
const next = enabled[(idx + delta + enabled.length) % enabled.length];
if (!next) return;
if (activation === "automatic") select(next.id);
else {
// focus only — keep a focusedId state in full impl
document.getElementById(`${baseId}-tab-${next.id}`)?.focus();
}
}
return (
<div className={orientation === "vertical" ? "tabs vertical" : "tabs"}>
<div
role="tablist"
aria-orientation={orientation}
aria-label="Sections"
onKeyDown={(e) => {
const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
if (e.key === nextKey) {
e.preventDefault();
move(1);
}
if (e.key === prevKey) {
e.preventDefault();
move(-1);
}
if (e.key === "Home") {
e.preventDefault();
const first = items.find((i) => !i.disabled);
if (first) select(first.id);
}
if (e.key === "End") {
e.preventDefault();
const last = [...items].reverse().find((i) => !i.disabled);
if (last) select(last.id);
}
}}
>
{items.map((item) => {
const selected = item.id === value;
return (
<button
key={item.id}
id={`${baseId}-tab-${item.id}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`${baseId}-panel-${item.id}`}
tabIndex={selected ? 0 : -1}
disabled={item.disabled}
onClick={() => select(item.id)}
>
{item.label}
</button>
);
})}
</div>
{items.map((item) => {
const selected = item.id === value;
return (
<div
key={item.id}
id={`${baseId}-panel-${item.id}`}
role="tabpanel"
aria-labelledby={`${baseId}-tab-${item.id}`}
hidden={!selected}
tabIndex={0}
>
{selected || true ? item.content : null}
{/* lazy: selected || mountedOnce */}
</div>
);
})}
</div>
);
}
Focus management: when selection changes via arrows, move DOM focus to the selected tab button.
Accessibility essentials
- Exactly the APG relationships tab ↔ panel
- Only one tab in tab order (
tabIndex={0}); others-1 - Disabled tabs skipped in keyboard loop
- Panels labeled by their tab
- Don’t nest interactive tabs patterns incorrectly inside another tablist
Performance notes
- Lazy-mount expensive panels
- Keep mounted panels if they hold form state
- Route-level code splitting if tabs are whole pages — might be routes instead of tabs
Footguns
- Clickable divs without roles
- All tabs tabIndex 0
- Hiding panels with CSS only but leaving focusable content inside hidden panels — use
hiddenorinert - Forgetting aria-controls ids
- Using tabs for sequential wizard steps — wizard is often better
Interview out-loud answer
Tabs follow the APG tablist pattern with roving tabindex and arrow keys. State is the active tab id, controlled or uncontrolled. Panels use role tabpanel and hidden when inactive. Automatic activation selects on arrow; manual waits for Enter. Lazy mounting is a performance option when panels are heavy.