How would you implement a Tabs component in React?
Compound component pattern: <Tabs value, onValueChange><Tabs.List><Tabs.Trigger value /></Tabs.List><Tabs.Panel value /></Tabs>. Use Context to share active value between List and Panels without prop drilling. A11y: ARIA roles tablist/tab/tabpanel, aria-selected, aria-controls/labelledby, keyboard support (Left/Right/Home/End to navigate, Tab to enter panel). Support controlled + uncontrolled. For production, just use Radix or React Aria — they handle the dozen edge cases.
A real Tabs component is a lot more than a few buttons. Production-quality means controlled + uncontrolled modes, full a11y, keyboard nav, and clean composition.
Quick demo using Radix (preferred in production)
import * as Tabs from '@radix-ui/react-tabs';
<Tabs.Root defaultValue="overview">
<Tabs.List aria-label="Settings sections">
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="billing">Billing</Tabs.Trigger>
<Tabs.Trigger value="team">Team</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">…</Tabs.Content>
<Tabs.Content value="billing">…</Tabs.Content>
<Tabs.Content value="team">…</Tabs.Content>
</Tabs.Root>A11y, keyboard nav, controlled/uncontrolled, focus management — all handled.
Hand-rolled (for interview / learning)
import { createContext, useContext, useId, useState, KeyboardEvent } from 'react';
type Ctx = {
value: string;
setValue: (v: string) => void;
baseId: string;
registerTrigger: (v: string, el: HTMLButtonElement | null) => void;
};
const TabsCtx = createContext<Ctx | null>(null);
const useTabsCtx = () => {
const c = useContext(TabsCtx);
if (!c) throw new Error('Tabs.* must be inside <Tabs>');
return c;
};
export function Tabs({
defaultValue,
value: controlled,
onValueChange,
children,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
children: React.ReactNode;
}) {
const [internal, setInternal] = useState(defaultValue ?? '');
const isControlled = controlled !== undefined;
const value = isControlled ? controlled : internal;
const setValue = (v: string) => {
if (!isControlled) setInternal(v);
onValueChange?.(v);
};
const baseId = useId();
const triggersRef = useRef(new Map<string, HTMLButtonElement>());
const registerTrigger = (v: string, el: HTMLButtonElement | null) => {
if (el) triggersRef.current.set(v, el);
else triggersRef.current.delete(v);
};
return (
<TabsCtx.Provider value={{ value, setValue, baseId, registerTrigger }}>
{children}
</TabsCtx.Provider>
);
}
Tabs.List = function TabList({ children, ...rest }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div role="tablist" {...rest}>
{children}
</div>
);
};
Tabs.Trigger = function TabTrigger({
value,
children,
...rest
}: { value: string; children: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const ctx = useTabsCtx();
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => { ctx.registerTrigger(value, ref.current); }, [value]);
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const order = Array.from(ctx.triggersRef?.current.keys() ?? []); // simplified
const idx = order.indexOf(value);
if (e.key === 'ArrowRight') { ctx.setValue(order[(idx + 1) % order.length]); e.preventDefault(); }
if (e.key === 'ArrowLeft') { ctx.setValue(order[(idx - 1 + order.length) % order.length]); e.preventDefault(); }
if (e.key === 'Home') { ctx.setValue(order[0]); e.preventDefault(); }
if (e.key === 'End') { ctx.setValue(order.at(-1)!); e.preventDefault(); }
};
const selected = ctx.value === value;
return (
<button
ref={ref}
role="tab"
aria-selected={selected}
aria-controls={`${ctx.baseId}-panel-${value}`}
id={`${ctx.baseId}-trigger-${value}`}
tabIndex={selected ? 0 : -1}
onClick={() => ctx.setValue(value)}
onKeyDown={onKeyDown}
{...rest}
>
{children}
</button>
);
};
Tabs.Panel = function TabPanel({
value,
children,
...rest
}: { value: string; children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) {
const ctx = useTabsCtx();
if (ctx.value !== value) return null;
return (
<div
role="tabpanel"
id={`${ctx.baseId}-panel-${value}`}
aria-labelledby={`${ctx.baseId}-trigger-${value}`}
tabIndex={0}
{...rest}
>
{children}
</div>
);
};What's important
- Compound component pattern: Tabs.List, Tabs.Trigger, Tabs.Panel share context. No prop drilling, composable in any order.
- Controlled + uncontrolled:
value+onValueChangefor controlled,defaultValuefor uncontrolled. - Roving tabindex: only the selected trigger has
tabIndex=0; others are-1. Arrow keys move within; Tab leaves the tablist. - ARIA:
tablist/tab/tabpanelroles,aria-selected,aria-controls,aria-labelledby. - Stable ids via
useIdfor SSR-safearia-controls/aria-labelledbylinking. - Keyboard: Arrow Left/Right (or Up/Down for vertical tabs), Home, End.
- Auto-activation vs manual activation: arrow can either activate immediately (auto, default for most) or just focus + Enter to activate (manual, for tabs that load expensive content).
Edge cases
- Lazy panel content:
{ctx.value === value ? children : null}unmounts inactive panels. Pros: saves render cost. Cons: panel state is lost on tab switch (form input clears). Alternative: render all panels, hide inactive with CSSdisplay: noneto preserve state. - URL-synced tabs: read/write
?tab=billingso refreshes preserve state and tabs are linkable. - Vertical orientation: support
orientation="vertical"and use Up/Down keys. - Dynamic tabs: register/unregister triggers as tabs are added/removed.
- Disabled tab: skip in keyboard nav.
Why use a library
The 80-line example above is missing:
- RTL support (Arrow key direction flips).
- Focus return on close (if tab opens overlay).
- IME composition handling.
- Animated indicator under active tab.
- SSR edge cases.
Radix UI, React Aria, or shadcn/ui handle all of it. For production, use one of them. Hand-rolling is fine for an interview problem.
Tests
- Click a trigger → panel switches.
- Arrow keys navigate.
- Home/End jump to first/last.
- Disabled tab is skipped.
- ARIA attributes correct.
- Focus visible.
- Works with screen reader (manual).
Mental model
Tabs = compound component with shared context, ARIA roles, roving tabindex, keyboard nav, controlled+uncontrolled API. The non-trivial complexity is in a11y; use a library if production.
Follow-up questions
- •What's the difference between automatic and manual activation tabs?
- •How do you persist active tab to the URL?
- •Why use compound components with context vs an array prop?
- •What does roving tabindex mean?
Common mistakes
- •No ARIA — tab a11y broken.
- •All triggers tabIndex=0 — Tab cycles through every trigger.
- •Forgetting controlled + uncontrolled support.
- •Unmounting inactive panels but expecting state preservation.
- •No keyboard navigation.
- •Hardcoding ids instead of useId — SSR conflicts.
Performance considerations
- •Most tabs are tiny — perf is rarely a concern. Render-all-panels-and-hide-with-CSS preserves state but mounts all content; lazy mounts only active panel but loses state. Pick per use case.
Edge cases
- •RTL: ArrowLeft → next; ArrowRight → previous.
- •Vertical tabs use Up/Down.
- •Disabled tabs skipped in keyboard nav.
- •Dynamic add/remove tabs while one is active.
- •Animated underline indicator requires measuring trigger positions.
Real-world examples
- •Radix UI Tabs — the canonical production implementation.
- •React Aria useTabList — Adobe's a11y-first approach.
- •shadcn/ui Tabs — Radix + Tailwind styling.
- •Material UI / Chakra UI tabs — opinionated styled versions.