Back

Accessible React Tabs

easy

Streak

0 days

Progress

0%

Submitted

0

Accessible React Tabs

React40 mineasyFreeNew

Prompt

A Tabs component is one of the most fundamental UI patterns in web development — you'll find it in settings pages, product detail views, dashboards, and documentation sites everywhere. Your task is to build an accessible, keyboard-navigable Tabs component in React. The component receives an array of tab objects (each with a label and content), renders a horizontal row of clickable tab buttons, and shows the content panel for the currently active tab. Only one tab is active at a time. What makes this question interesting at a senior level is not the state management — that's simple. It's the accessibility requirements. Tabs are a defined WAI-ARIA pattern with specific roles, attributes, and keyboard interactions. An interviewer asking this question is specifically testing whether you know how to implement a component that works for everyone — mouse users, keyboard users, and screen reader users alike.

Requirements

  • →Render clickable tab buttons in a horizontal row
  • →Show only the content panel for the currently active tab
  • →First tab is active by default
  • →Visually highlight the active tab with a bottom border or background change
  • →Keyboard navigation: ArrowLeft and ArrowRight switch between tabs
  • →Tab key moves focus into the active panel, not between tabs
  • →Correct ARIA roles: role='tablist' on the container, role='tab' on each button, role='tabpanel' on the content
  • →aria-selected='true' on the active tab, aria-controls linking tab to its panel
Example
Loading preview...
For the best coding experience, we recommend using a desktop device.
Preparing Sandbox...
Premium interview report

What interviewers score in this build

Use this before reading the code. It tells you what to say, what to test, and where machine-coding candidates usually lose points.

Interview signals

  • React: Uses a single activeIndex state. Content derived from tabs[activeIndex]. Keyboard handler updates index and calls .focus() on the new tab. useRef array stores refs to all tab buttons.
  • HTML/CSS: role=tablist on wrapper, role=tab on each button, role=tabpanel on content div. aria-selected on tabs, aria-controls linking tab to panel id, aria-labelledby linking panel back to tab. Active tab gets tabIndex=0, others get tabIndex=-1.
  • Component Architecture: Single Tabs component accepting tabs prop (array of {label, content}). Optionally accepts defaultActiveIndex and onChange callback. Self-contained — no external state needed.
  • State Management: Only activeIndex in state. Content is derived. The tab buttons' focus behavior is managed via tabIndex values and programmatic .focus() calls — not via additional state.

Time checkpoints

  1. 1

    0:00: Read prompt. Ask: What is the tab data format? Is keyboard nav required? Any animation on panel switch?

  2. 2

    3:00: Plan state (activeIndex), component API (tabs prop shape), and ARIA structure before coding.

  3. 3

    7:00: Render tab buttons and active panel. Wire up click to set activeIndex.

  4. 4

    12:00: Add ARIA roles: tablist, tab, tabpanel. Add aria-selected, aria-controls, aria-labelledby.

Edge-case checklist

Empty data and first-load state
Slow network, failed request, and retry path
Keyboard navigation and focus movement
Large input size, re-render pressure, and cleanup

Common mistakes

  • Starting with JSX before naming state and events.
  • Ignoring accessibility until the final minute.
  • Over-building abstractions instead of finishing the required behavior.
  • Failing to narrate trade-offs while coding.
SolutionRead-only · Live Preview

Technical Explanation

Problem Understanding

Let me be direct: the reason interviewers assign a Tabs component is not to test if you can render a list of buttons. They know you can. They're testing whether you know the WAI-ARIA Tabs pattern — a specification that defines exactly how tabs should behave for keyboard and screen reader users. Most candidates get the mouse interaction right and completely miss the accessibility layer. That's the differentiator.

Before writing a line of code, ask these questions:

  • What is the shape of the tabs prop — an array of objects? What properties does each have?
  • Should tabs be able to be closed or added dynamically?
  • Is keyboard navigation required? (Almost always yes, but confirm)
  • Should there be a transition animation when switching panels?

Component Architecture

One clean, self-contained Tabs component is the right call here. It accepts a tabs prop and optionally a defaultActiveIndex:

// Usage
<Tabs
  tabs={[
    { id: 'overview', label: 'Overview', content: <OverviewPanel /> },
    { id: 'specs',    label: 'Specs',    content: <SpecsPanel /> },
    { id: 'reviews',  label: 'Reviews',  content: <ReviewsPanel /> },
  ]}
/>

Notice I'm using an id field on each tab. This is important for the ARIA aria-controls and aria-labelledby attributes, and it makes the code much more maintainable than relying on array indices as IDs in the DOM.

State Design

One piece of state: activeIndex. Period.

const [activeIndex, setActiveIndex] = useState(0);

The active panel content is derived:

const activeTab = tabs[activeIndex]; // derived — not state
⚠️ Common Mistake: Storing the active tab's content or label in a separate state variable. This creates two sources of truth that can go out of sync. If something can be calculated from existing state — and it can here — it should never be its own state variable. This is one of the most common React anti-patterns.

The ARIA Pattern — This Is the Key Section

The WAI-ARIA specification defines exactly how a tabs widget should work. Here's what you need to implement:

// The container wrapping all tab buttons
<div role="tablist" aria-label="Product details">

  // Each tab button
  <button
    role="tab"
    id={`tab-${tab.id}`}           // unique ID for this button
    aria-selected={i === activeIndex} // true only for active tab
    aria-controls={`panel-${tab.id}`} // which panel this tab controls
    tabIndex={i === activeIndex ? 0 : -1} // only active tab in tab order!
  >
    {tab.label}
  </button>

// The content panel
<div
  role="tabpanel"
  id={`panel-${tab.id}`}            // matches tab's aria-controls
  aria-labelledby={`tab-${tab.id}`} // references the tab button
  tabIndex={0}                       // panel is focusable for Tab key
>
💡 Interview Tip: The tabIndex={i === activeIndex ? 0 : -1} pattern is called "roving tabindex". It's the ARIA-recommended way to manage focus for widgets like tabs, toolbars, and menus. Only one element in the group is in the natural tab order at a time, and arrow keys move focus within the group. Naming this pattern by name will immediately impress your interviewer.

Step-by-Step Implementation

Tab Refs for Focus Management

const tabRefs = useRef([]); // array of refs, one per tab button

We need refs to the tab buttons so we can programmatically call .focus() when the user presses arrow keys. Without this, state updates but the visual focus indicator stays on the old button — a confusing experience for keyboard users.

Keyboard Handler

function handleKeyDown(e, currentIndex) {
  let newIndex = currentIndex;

  if (e.key === 'ArrowRight') {
    e.preventDefault(); // prevent page scroll
    newIndex = (currentIndex + 1) % tabs.length;
  } else if (e.key === 'ArrowLeft') {
    e.preventDefault();
    newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
  } else if (e.key === 'Home') {
    e.preventDefault();
    newIndex = 0;
  } else if (e.key === 'End') {
    e.preventDefault();
    newIndex = tabs.length - 1;
  } else {
    return; // don't handle other keys
  }

  setActiveIndex(newIndex);
  tabRefs.current[newIndex]?.focus(); // move visual focus immediately
}
💡 Interview Tip: Adding Home and End key support (jump to first/last tab) is a bonus that shows you've actually read the ARIA spec. The basic requirement is just ArrowLeft and ArrowRight, but including Home/End takes 4 extra lines and signals genuine expertise.

The Full Component

export default function Tabs({ tabs = [], defaultActiveIndex = 0 }) {
  const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
  const tabRefs = useRef([]);

  if (tabs.length === 0) return null;

  function handleKeyDown(e, currentIndex) {
    let newIndex = currentIndex;
    if (e.key === 'ArrowRight') newIndex = (currentIndex + 1) % tabs.length;
    else if (e.key === 'ArrowLeft') newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
    else if (e.key === 'Home') newIndex = 0;
    else if (e.key === 'End') newIndex = tabs.length - 1;
    else return;
    e.preventDefault();
    setActiveIndex(newIndex);
    tabRefs.current[newIndex]?.focus();
  }

  return (
    <div className="tabs">
      {/* Tab list */}
      <div role="tablist" className="tab-list">
        {tabs.map((tab, i) => (
          <button
            key={tab.id}
            ref={el => tabRefs.current[i] = el}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={i === activeIndex}
            aria-controls={`panel-${tab.id}`}
            tabIndex={i === activeIndex ? 0 : -1}
            className={`tab-btn ${i === activeIndex ? 'tab-btn--active' : ''}`}
            onClick={() => setActiveIndex(i)}
            onKeyDown={e => handleKeyDown(e, i)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {/* Panel — only render the active one */}
      <div
        role="tabpanel"
        id={`panel-${tabs[activeIndex].id}`}
        aria-labelledby={`tab-${tabs[activeIndex].id}`}
        tabIndex={0}
        className="tab-panel"
      >
        {tabs[activeIndex].content}
      </div>
    </div>
  );
}

Core Logic — Why tabIndex={-1} on Inactive Tabs?

The natural browser tab order would make the user Tab through every tab button before reaching the content. With 10 tabs, that's 10 Tab presses just to get to the content. The roving tabindex pattern solves this:

  • Only the active tab button has tabIndex={0} — it's the only one in the natural tab order
  • All other tab buttons have tabIndex={-1} — they're reachable only via arrow keys
  • The panel has tabIndex={0} — pressing Tab from the active tab moves focus directly into the panel

This is the correct, specification-compliant behavior. Most implementations get this wrong.

Styling Approach

.tab-list {
  display: flex;
  border-bottom: 2px solid #e2e8f0;
  gap: 0;
}

.tab-btn {
  padding: 12px 24px;
  border: none;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  color: #64748b;
  border-bottom: 3px solid transparent;
  margin-bottom: -2px; /* overlap the tablist border */
  transition: color 0.15s, border-color 0.15s;
}

.tab-btn--active {
  color: #3b82f6;
  border-bottom-color: #3b82f6;
}

.tab-btn:hover:not(.tab-btn--active) {
  color: #475569;
}

.tab-panel {
  padding: 24px 0;
  outline: none; /* tabpanel is focusable but shouldn't show focus ring */
}
💡 Interview Tip: The margin-bottom: -2px trick on the active tab button makes its bottom border overlap the tablist's border, creating the connected "active tab" visual effect. Without it, you'd see a gap or double border. This is a CSS detail that shows you've built real tabs before.
⚠️ Common Mistake: Using [aria-selected="true"] as a CSS selector for the active tab styling instead of a CSS class. While this works, it tightly couples your visual styling to your ARIA attributes. A better approach is to use a dedicated CSS class (tab-btn--active) alongside the ARIA attribute, keeping concerns separate.

Common Pitfalls

  • Missing ARIA roles entirely: The biggest miss for this question. Without role=tab, role=tablist, and role=tabpanel, screen readers can't understand the component's structure.
  • All tabs in the tab order: Setting tabIndex={0} on every tab button instead of using the roving tabindex pattern. This creates poor keyboard UX.
  • Not moving focus programmatically: Updating state on arrow key press without calling .focus() on the new active tab. The state changes, but the visual focus indicator stays on the old button — deeply confusing for keyboard users.
  • Using indices as DOM ids: Using id={`tab-${i}`} is brittle when tabs can be added/removed. Use a stable id from the data, like tab.id.
  • Rendering all panels and hiding with CSS: display:none on inactive panels keeps them in the DOM but removes them from the accessibility tree. Only render the active panel unless you need the inactive panels to preserve scroll position.

How to Communicate During the Interview

The ARIA knowledge is what makes this answer stand out. Say it explicitly:

  • "Tabs are a defined WAI-ARIA widget pattern. I'm using role=tablist on the container, role=tab on each button, and role=tabpanel on the content div."
  • "I'm implementing the roving tabindex pattern — only the active tab has tabIndex=0, so Tab key jumps directly from the active tab to the panel. Inactive tabs are only reachable via arrow keys."
  • "When the user presses ArrowRight, I update state AND call .focus() on the new tab button. Both need to happen — updating state alone doesn't move the visual focus ring."
  • "I'm using aria-controls to link each tab to its panel, and aria-labelledby to link the panel back to its tab. This gives screen readers the full relational context."

This level of explanation tells the interviewer you've actually built production-quality accessible components before — not just thrown some divs on a page.

Interview Criteria

React

Uses a single activeIndex state. Content derived from tabs[activeIndex]. Keyboard handler updates index and calls .focus() on the new tab. useRef array stores refs to all tab buttons.

HTML/CSS

role=tablist on wrapper, role=tab on each button, role=tabpanel on content div. aria-selected on tabs, aria-controls linking tab to panel id, aria-labelledby linking panel back to tab. Active tab gets tabIndex=0, others get tabIndex=-1.

Component Architecture

Single Tabs component accepting tabs prop (array of {label, content}). Optionally accepts defaultActiveIndex and onChange callback. Self-contained — no external state needed.

State Management

Only activeIndex in state. Content is derived. The tab buttons' focus behavior is managed via tabIndex values and programmatic .focus() calls — not via additional state.

Edge Cases

Handles empty tabs array. Arrow key navigation wraps at both ends. Tab key moves to panel, not next tab. Focus is restored to the correct tab after navigating away and returning.

Time Checkpoints

0:00

0:00: Read prompt. Ask: What is the tab data format? Is keyboard nav required? Any animation on panel switch?

3:00

3:00: Plan state (activeIndex), component API (tabs prop shape), and ARIA structure before coding.

7:00

7:00: Render tab buttons and active panel. Wire up click to set activeIndex.

12:00

12:00: Add ARIA roles: tablist, tab, tabpanel. Add aria-selected, aria-controls, aria-labelledby.

17:00

17:00: Add tabIndex management (0 for active, -1 for others). Set up useRef array for tab buttons.

22:00

22:00: Implement ArrowLeft/ArrowRight keyboard navigation with focus management.

27:00

27:00: Style active tab indicator, panel content area, and handle edge cases.

Streak

0 days

Last active: Sign in to track

Progress

0%

0/0 solved

Submitted

0

Solutions pushed to review history.