Back to React
React
easy
mid

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.

9 min read·~30 min to think through

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)

tsx
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)

tsx
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

  1. Compound component pattern: Tabs.List, Tabs.Trigger, Tabs.Panel share context. No prop drilling, composable in any order.
  2. Controlled + uncontrolled: value + onValueChange for controlled, defaultValue for uncontrolled.
  3. Roving tabindex: only the selected trigger has tabIndex=0; others are -1. Arrow keys move within; Tab leaves the tablist.
  4. ARIA: tablist / tab / tabpanel roles, aria-selected, aria-controls, aria-labelledby.
  5. Stable ids via useId for SSR-safe aria-controls/aria-labelledby linking.
  6. Keyboard: Arrow Left/Right (or Up/Down for vertical tabs), Home, End.
  7. 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 CSS display: none to preserve state.
  • URL-synced tabs: read/write ?tab=billing so 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.

Senior engineer discussion

Seniors reach for Radix or React Aria in production. For interviews, they implement the compound-component-with-context pattern, talk through a11y (ARIA, roving tabindex, keyboard), and surface the controlled/uncontrolled API and lazy/eager panel mount tradeoff.

Related questions