ESC

Type to search the knowledge base.

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, id wiring

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)

  1. Automatic vs manual activation — automatic selects on focus (common); manual needs Enter
  2. Roving tabindex on tabs
  3. Ids via useId
  4. MVP — click + roles; then arrows
  5. 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

  1. Clickable divs without roles
  2. All tabs tabIndex 0
  3. Hiding panels with CSS only but leaving focusable content inside hidden panels — use hidden or inert
  4. Forgetting aria-controls ids
  5. 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.

Further reading