← Back to question bank
UI ComponentSeniorHard#3015 · 60m

Build a tabs with keyboard navigation

Design the behavior contract for a tabs with keyboard navigation. Focus on state, keyboard interaction, empty/loading/error states, and how the component composes with product data.

Answer Strategy

Tabs are the canonical roving tabindex question. The interview signal is whether you understand the WAI-ARIA Tabs Pattern: the tablist is one stop in the page tab order, arrow keys cycle inside it, only the selected tab has tabindex=0, and the panel is associated by aria-labelledby/aria-controls so screen readers know what the tab governs.

Lead with the activation mode tradeoff. Automatic activation (panel switches as focus moves) is right when content is cheap and immediate; manual activation (Enter/Space commits) is right when each tab loads expensive data, would lose draft state, or fires analytics. Naming this tradeoff up front is the senior signal.

Volunteer the failures. Without roving tabindex, keyboard users have to tab through every tab to leave the list. Without aria-labelledby on the panel, screen readers cannot connect content back to its tab. With panels that contain forms, focus must visit the panel itself (tabindex=0) so users tabbing past the tablist do not skip the content. Without preserving panel state between switches, drafts vanish on accidental arrow presses.

Reference Implementation: Tabs With Roving Tabindex And Activation Mode

A controlled tablist that supports both automatic and manual activation, plus full ArrowLeft/ArrowRight/Home/End/Enter/Space keyboard semantics.

type TabsProps = {
  label: string;
  tabs: Array<{ id: string; label: string; panel: React.ReactNode }>;
  // Manual activation = focus moves but content does not change until Enter/Space.
  // Automatic activation = arrow key both moves focus and switches the panel.
  // Manual is friendlier when each tab triggers expensive work; automatic is
  // friendlier for cheap content swaps.
  activation?: 'manual' | 'automatic';
};

export function Tabs({ label, tabs, activation = 'automatic' }: TabsProps) {
  const [selectedId, setSelectedId] = React.useState(tabs[0]?.id ?? '');
  const tabRefs = React.useRef<Array<HTMLButtonElement | null>>([]);

  function focusTab(index: number) {
    const next = (index + tabs.length) % tabs.length;
    tabRefs.current[next]?.focus();
    if (activation === 'automatic') setSelectedId(tabs[next].id);
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>, index: number) {
    if (event.key === 'ArrowRight') {
      event.preventDefault();
      focusTab(index + 1);
    } else if (event.key === 'ArrowLeft') {
      event.preventDefault();
      focusTab(index - 1);
    } else if (event.key === 'Home') {
      event.preventDefault();
      focusTab(0);
    } else if (event.key === 'End') {
      event.preventDefault();
      focusTab(tabs.length - 1);
    } else if ((event.key === 'Enter' || event.key === ' ') && activation === 'manual') {
      event.preventDefault();
      setSelectedId(tabs[index].id);
    }
  }

  const selectedIndex = Math.max(0, tabs.findIndex((tab) => tab.id === selectedId));
  const panel = tabs[selectedIndex]?.panel;

  return (
    <div className="tabs-root">
      <div role="tablist" aria-label={label} onKeyDown={()=> {}}>
        {tabs.map((tab, index) => {
          const selected = tab.id === selectedId;
          return (
            <button
              key={tab.id}
              ref={(node)=> {
                tabRefs.current[index]= node;
              }}
              role="tab"
              type="button"
              aria-selected={selected}
              aria-controls={'panel-' + tab.id}
              id={'tab-' + tab.id}
              // Roving tabindex: only the selected tab is reachable from
              // outside the tablist; arrow keys cycle within.
              tabIndex={selected ? 0 : -1}
              onClick={()=> setSelectedId(tab.id)}
              onKeyDown={(event)=> onKeyDown(event, index)}
            >
              {tab.label}
            </button>
          );
        })}
      </div>
      <section
        role="tabpanel"
        id={'panel-' + selectedId}
        aria-labelledby={'tab-' + selectedId}
        // tabindex=0 makes the panel itself focusable; users tabbing past
        // the tablist land in the panel content rather than skipping it.
        tabIndex={0}
      >
        {panel}
      </section>
    </div>
  );
}

Executable UI Sandbox

UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.

Browser sandbox
HTML, CSS, DOM events, focus, and keyboard behavior
Preview running...
Loading editor...

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Roles
Assert role=tablist with aria-label, role=tab with aria-selected and aria-controls, and role=tabpanel with aria-labelledby.
Keyboard
Test ArrowLeft/ArrowRight wrap, Home/End, and that manual activation only changes selection on Enter/Space. Confirm DOM focus follows arrow keys.
Roving
Verify only the selected tab has tabindex=0; tabbing into the tablist lands on the selected tab; tabbing out of the tablist lands inside the panel.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('Tabs', () => {
  const tabs = [
    { id: 'a', label: 'Apples', panel: <p>About apples</p> },
    { id: 'b', label: 'Berries', panel: <p>About berries</p> },
    { id: 'c', label: 'Citrus', panel: <p>About citrus</p> },
  ];

  it('moves focus and selection with arrow keys (automatic)', async () => {
    const user = userEvent.setup();
    render(<Tabs label="Fruits" tabs={tabs} />);
    const apples = screen.getByRole('tab', { name: 'Apples' });
    apples.focus();
    await user.keyboard('{ArrowRight}');
    expect(screen.getByRole('tab', { name: 'Berries' })).toHaveFocus();
    expect(screen.getByRole('tab', { name: 'Berries' })).toHaveAttribute('aria-selected', 'true');
  });

  it('Home and End jump to extremes', async () => {
    const user = userEvent.setup();
    render(<Tabs label="Fruits" tabs={tabs} />);
    screen.getByRole('tab', { name: 'Apples' }).focus();
    await user.keyboard('{End}');
    expect(screen.getByRole('tab', { name: 'Citrus' })).toHaveFocus();
    await user.keyboard('{Home}');
    expect(screen.getByRole('tab', { name: 'Apples' })).toHaveFocus();
  });

  it('manual activation only switches panel on Enter/Space', async () => {
    const user = userEvent.setup();
    render(<Tabs label="Fruits" tabs={tabs} activation="manual" />);
    screen.getByRole('tab', { name: 'Apples' }).focus();
    await user.keyboard('{ArrowRight}');
    expect(screen.getByRole('tab', { name: 'Berries' })).toHaveFocus();
    expect(screen.getByRole('tab', { name: 'Berries' })).toHaveAttribute('aria-selected', 'false');
    await user.keyboard('{Enter}');
    expect(screen.getByRole('tab', { name: 'Berries' })).toHaveAttribute('aria-selected', 'true');
  });
});

Interviewer Signal

Shows whether you can build components as interaction systems rather than visual boxes.

Constraints

  • Name the controlled and uncontrolled state.
  • Define keyboard and focus behavior.
  • Include loading, empty, disabled, and error states.

Model Answer Shape

  • Start with the accessibility role and interaction contract.
  • Separate rendering slots from state management.
  • Expose callbacks that describe user intent, not internal implementation details.

Tradeoffs

  • A headless primitive is reusable but slower to consume.
  • A product-specific component ships faster but can trap behavior in one use case.

Edge Cases

  • Focus after close, selection, deletion, or route change.
  • Large datasets and slow network responses.
  • Screen reader labels and live updates.

Testing And Proof

  • Keyboard path through the primary workflow.
  • A11y names, descriptions, and roles.
  • State transition after slow or failed data load.

Follow-Ups

  • How would this component be documented in a design system?
  • What props would you refuse to expose?