Back to Machine Coding
Machine Coding
easy
junior

How would you 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.

7 min read·~30 min to think through

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.

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

tsx
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-controls pointing to the panel id.
  • role="tabpanel" on each panel. aria-labelledby pointing 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:

tsx
{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-labelledby linking 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

tsx
export function Tabs({ defaultIndex = 0, children }: { defaultIndex?: number; children: React.ReactNode }) {
  const [active, setActive] = useState(defaultIndex);
  const baseId = useId();
  // Inject indices to children via context-aware wrappers
  return <TabsCtx.Provider value={{ active, setActive, baseId }}>{children}</TabsCtx.Provider>;
}

export function TabList({ children }: { children: React.ReactNode }) {
  return (
    <div role="tablist">
      {React.Children.map(children, (child, i) =>
        React.isValidElement(child) ? React.cloneElement(child, { __index: i } as any) : child
      )}
    </div>
  );
}
Cleaner indexing via React.Children

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

Senior engineer discussion

Senior signal: roving tabindex, ARIA pairing, manual vs auto-activation choice, and compound-component API ergonomics.

Related questions