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.