Back to Machine Coding
Machine Coding
easy
mid

How would you build a Tabs component from scratch?

State = the active tab id. Render a tablist of buttons and the active panel. The grading is accessibility: role=tablist/tab/tabpanel, aria-selected, roving tabindex, and arrow-key navigation between tabs. Often built as a compound component (Tabs/Tab/TabPanel) sharing state via Context.

4 min read·~20 min to think through

A Tabs component is simple state-wise — activeTab — but interviewers grade the accessibility and the API design.

Minimal implementation

jsx
function Tabs({ tabs }) {  // tabs: [{ id, label, content }]
  const [active, setActive] = useState(tabs[0].id);

  const onKeyDown = (e, idx) => {
    if (e.key === "ArrowRight") setActive(tabs[(idx + 1) % tabs.length].id);
    if (e.key === "ArrowLeft")  setActive(tabs[(idx - 1 + tabs.length) % tabs.length].id);
  };

  return (
    <div>
      <div role="tablist">
        {tabs.map((tab, idx) => (
          <button
            key={tab.id}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={active === tab.id}
            aria-controls={`panel-${tab.id}`}
            tabIndex={active === tab.id ? 0 : -1}     {/* roving tabindex */}
            onClick={() => setActive(tab.id)}
            onKeyDown={(e) => onKeyDown(e, idx)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={active !== tab.id}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

What's actually being graded — accessibility

This is a WAI-ARIA pattern; getting it right is the point:

  • role="tablist", role="tab" on each button, role="tabpanel" on each panel.
  • aria-selected on the active tab; aria-controls links tab → panel; aria-labelledby links panel → tab.
  • Roving tabindex — only the active tab is tabIndex={0}; the rest are -1. So Tab moves into and out of the tablist as one stop; arrow keys move between tabs.
  • Arrow-key navigation — Left/Right (Up/Down for vertical) cycle tabs; Home/End jump to first/last.
  • Use real <button>s so Enter/Space work for free.

API design — compound component

For a reusable library version, build it as a compound component sharing state via Context:

jsx
<Tabs defaultValue="a">
  <TabList>
    <Tab value="a">First</Tab>
    <Tab value="b">Second</Tab>
  </TabList>
  <TabPanel value="a">...</TabPanel>
  <TabPanel value="b">...</TabPanel>
</Tabs>

Tabs holds active in Context; Tab/TabPanel read it. More flexible than a tabs prop array.

Other considerations

  • Controlled vs uncontrolled — support both (value/onChange or defaultValue).
  • Lazy panels — optionally only mount the active panel's content.
  • Sync active tab to the URL for deep-linking.

The framing

"State is just activeTab. What's graded is the WAI-ARIA tabs pattern: role=tablist/tab/tabpanel, aria-selected, aria-controls/aria-labelledby wiring tabs to panels, and crucially a roving tabindex so the tablist is one Tab-stop and arrow keys move between tabs. Real <button>s give Enter/Space for free. For a library API I'd build it as a compound component — Tabs/TabList/Tab/TabPanel sharing state via Context — and support controlled and uncontrolled modes."

Follow-up questions

  • What is roving tabindex and why is it needed here?
  • Which ARIA roles and attributes does the tabs pattern require?
  • How would you design this as a compound component?
  • How do you support both controlled and uncontrolled usage?

Common mistakes

  • Divs with onClick instead of <button>s — no keyboard support.
  • Missing roving tabindex, so all tabs are tab stops.
  • No arrow-key navigation between tabs.
  • Missing aria-selected / aria-controls wiring.
  • Rendering all panels visible or all mounted with no hidden attribute.

Performance considerations

  • Trivial. Lazy-mounting only the active panel's content avoids rendering expensive hidden panels; keeping panels mounted but hidden preserves their state — a deliberate trade-off.

Edge cases

  • Wrapping from last tab to first with arrow keys.
  • A disabled tab — skip it in keyboard nav.
  • Many tabs overflowing — scroll or overflow menu.
  • Vertical tabs (Up/Down instead of Left/Right).
  • Deep-linking to a specific tab.

Real-world examples

  • Settings pages, product detail sections, dashboard views.
  • Radix UI / Headless UI Tabs implementing exactly this ARIA pattern.

Senior engineer discussion

Seniors implement the full WAI-ARIA tabs pattern including roving tabindex and arrow-key nav, use real buttons, design a compound-component API with Context, and support controlled/uncontrolled plus lazy panels.

Related questions