Design a tabs component
A design-system tabs component that shows one panel at a time. The row of buttons is easy; the real work is a clean compound API, the WAI-ARIA tabs pattern with roving focus and the right activation mode, ids that survive server rendering, deciding when hidden panels stay mounted, syncing the selected tab with the URL, loading data per tab without races, an animated indicator, and tab rows that don't fit on a phone.
The question
Design a tabs component for our design system. Product pages use it for Overview, Reviews and Specs. The account area uses it for settings sections, and docs pages use it to switch code samples between languages.
Walk me through the API, how it works with a keyboard and a screen reader, what happens to the panels you're not looking at, how the selected tab ends up in the URL, and what you'd do when six tabs don't fit on a 360 pixel wide phone.
Real-life examples: The Overview, Reviews and Specs tabs on an Amazon product page, GitHub's Code, Issues and Pull requests bar on a repository, The settings sections in Stripe's dashboard, The Preview and Code tabs on a docs code sample, Chrome DevTools' Elements, Console and Network panels
Requirements: what to ask before drawing anything
Tabs are a set of buttons in a row, called the tab list, where each button shows one panel of content and hides the others. Only one panel is visible at a time. You've used them on a product page (Overview, Reviews, Specs), in a settings screen, and in every code editor.
Tabs look like the easiest component in a design system: a few buttons and a useState. That's why interviewers like them.
A junior answer stops at the buttons. A senior answer asks what the panels contain, where the selected tab is stored, how a keyboard user moves between tabs, and what happens on a small screen. Each of those questions turns into a real decision with trade-offs.
Start by asking questions. The middle column is what this guide assumes. The right column is why the answer matters.
| Question | Answer we assume | What it changes |
|---|---|---|
| How many tabs? | Usually 2 to 6. One admin page has 11. | Up to about 6 fit on a laptop. On a 360 pixel phone even 4 may not, so we need an overflow plan. |
| What's inside a panel? | Anything: text, a form, a chart, a list of 200 reviews loaded from an API. | Panels can be heavy and can hold user state (a half-typed form), so mounting rules matter. |
| Should the selected tab be in the URL? | Yes on product and settings pages, so links and the back button work. No for small widgets like code samples. | The component must support a controlled mode, where the page owns the value. |
| Does each panel fetch its own data? | Reviews and Specs do. Overview comes with the page. | Loading and error states per panel, prefetching, and stale responses when users click fast. |
| Server rendered? | Yes, the app uses server rendering with hydration. | The ids that link tabs to panels must match on the server and the client. |
| Which devices and languages? | Last two versions of the major browsers, about half the traffic on phones, and Arabic and Hebrew locales. | Overflow on narrow screens, touch targets of at least 24 by 24 pixels, and arrow keys that flip in right-to-left layouts. |
| Vertical tabs too? | Yes, settings pages put the tab list on the left side. | An orientation prop that changes the arrow keys from Left and Right to Up and Down. |
| Accessibility target? | WCAG 2.2 AA, following the WAI-ARIA Authoring Practices (APG) tabs pattern. | Roles, states, roving focus, and a chosen activation mode. |
Functional requirements
- Show a list of tabs and exactly one visible panel. Clicking or tapping a tab shows its panel.
- Work fully from the keyboard: Tab moves into the tab list and then to the panel, arrow keys move between tabs, Home and End jump to the first and last tab.
- Support disabled tabs that can't be selected, with a reason shown nearby.
- Support horizontal and vertical layouts.
- Work uncontrolled (the component remembers the selected tab) and controlled (the page passes the selected value, for example from the URL).
- Let the page choose what happens to hidden panels: unmount them, keep them mounted but hidden, or render them only once they've been visited.
- Stay usable when the tabs don't fit: scroll, collapse into a menu, or switch to another layout.
Non-functional requirements
- Fast switching. A tab change should paint within one frame or two (under about 50 ms) for content that's already loaded, well inside the 200 ms budget for a good Interaction to Next Paint (INP), the Core Web Vital that measures how quickly the page responds to input.
- Small. The core (without the indicator animation) should be a few kilobytes gzipped, because it ships on product pages where every kilobyte counts.
- Accessible. Correct roles and states, visible focus, no keyboard traps, and motion that respects
prefers-reduced-motion. - No layout shift. Switching tabs or loading a panel must not push the page around in ways that count toward Cumulative Layout Shift (CLS).
- Server rendering safe. The first HTML shows the right tab with real content, and hydration produces no mismatch warnings.
Out of scope, and why
- Closable and reorderable tabs, like browser tabs or editor tabs. That's a different widget with drag and drop, close buttons, and a tab strip state machine. Mention it, then set it aside.
- Site navigation that looks like tabs but loads a new page for each item. We'll come back to this one in chapter 9, because choosing the wrong pattern is a common accessibility bug.
- The content of each panel. We design the container, not the reviews list inside it.
Check your understanding · Exercise 1
Which answers from the interviewer change the design of the tabs component itself, not just the content inside it? Select all that apply.
What makes tabs harder than they look
Here's the naive version most people write first. It works for a mouse user on a laptop, and it breaks in at least six ways.
function Tabs({ labels, children }) {
const [index, setIndex] = useState(0);
return (
<div>
{labels.map((label, i) => (
<button key={label} onClick={() => setIndex(i)}
className={i === index ? "active" : ""}>
{label}
</button>
))}
{children[index]}
</div>
);
}
- A screen reader hears six unrelated buttons. Nothing says they're tabs, which one is selected, or which content they control. The
activeclass is only visual. - The keyboard walks every tab. Pressing Tab stops on each button before reaching the content. With 6 tabs, a keyboard user presses Tab 6 times to get past them, on every visit.
- Switching tabs throws away work.
children[index]unmounts the old panel. A half-typed review, a scroll position, and a loaded chart are all gone when you come back. - The index is fragile. Store
2in the URL and then marketing adds a "Q&A" tab at position 1. Every shared link now opens the wrong tab. - Nobody else can see the state. The URL doesn't change, so refresh, back, and shared links all land on the first tab.
- It doesn't fit on a phone. Six buttons in a row wrap onto two lines or overflow off the right edge, and the selected one may be off screen.
So the problem has five forces that drive every later chapter:
| Force | What it means | Chapter |
|---|---|---|
| Many owners of one value | The selected tab may belong to the component, the page, or the URL, depending on who uses it. | 3 (API) and 6 (URL) |
| A strict accessibility contract | Roles, states, links between tab and panel, and a specific keyboard model that screen reader users already know. | 4 |
| Panels with weight and memory | Panels can cost hundreds of DOM nodes and network requests, and can hold state the user cares about. | 5 |
| Data that arrives late | Panels that fetch can show stale or out-of-order data when the user clicks fast. | 6 |
| Space that runs out | Labels are translated, longer in German, mirrored in Arabic, and the screen may be 360 pixels wide. | 7 |
Think first
There's no native tabs element in HTML. What's the closest thing the browser gives you for free, and why isn't it enough here?
The closest is a group of details elements that share a name attribute. Since 2024, all major browsers treat them as an exclusive group: opening one closes the others. That gives you "one panel at a time" with no JavaScript, and in current browsers find-in-page can open a closed one.
But it's an accordion, not tabs. The labels sit above each panel instead of in one row, the keyboard model is Tab between summaries instead of arrow keys, and screen readers announce "disclosure" or "expanded", not "tab, 2 of 4, selected". It's a good fallback for small screens (chapter 7), not a replacement.
The component API: who owns the selected tab
The API is the first thing other teams touch, and the hardest thing to change later. The component API design lesson covers the general rules; here we apply them to tabs. There are three common shapes.
Decision What shape should the public API have?
What we pick: Compound components for the public API, built on an internal hook. Product teams get structure that's hard to break; the rare team that needs full control can use the hook. A thin config-array wrapper can sit on top for CMS-driven pages, so nobody has to choose between them.
<Tabs defaultValue="overview" activation="automatic">
<TabList aria-label="Product details">
<Tab value="overview">Overview</Tab>
<Tab value="reviews">Reviews <Badge>212</Badge></Tab>
<Tab value="specs">Specs</Tab>
<Tab value="qa" disabled>Q&A</Tab>
</TabList>
<TabPanel value="overview" mount="eager">...</TabPanel>
<TabPanel value="reviews" mount="lazy">...</TabPanel>
<TabPanel value="specs">...</TabPanel>
<TabPanel value="qa">...</TabPanel>
</Tabs>
String values, not indexes
Each Tab and TabPanel pair shares a value like "reviews". The naive version used positions. Positions break when a tab is added, removed, reordered, or hidden behind a feature flag, and they make URLs meaningless (?tab=2).
A string survives all of that, reads well in a URL (?tab=reviews), and shows up clearly in analytics events. It also lets a Tab and its TabPanel sit far apart in the tree, for example when the tab list is in a sticky header.
Controlled and uncontrolled
An uncontrolled component keeps its own state: you pass defaultValue once and it remembers the selection. A controlled component gets the current value from its parent on every render and calls onValueChange to ask the parent to change it. Docs code samples want the first. A product page that keeps the tab in the URL wants the second, because the URL is the owner.
Support both with one small hook. If value is passed, use it and never store a copy; otherwise keep internal state seeded from defaultValue. Switching between the two modes during the component's life is a bug in the caller, so warn in development, the way React warns for inputs.
function useControllable<T>(value: T | undefined, defaultValue: T,
onChange?: (next: T) => void) {
const [inner, setInner] = useState(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : inner;
const set = useCallback((next: T) => {
if (next === current) return;
if (!controlled) setInner(next);
onChange?.(next);
}, [controlled, current, onChange]);
return [current, set] as const;
}
Disabled tabs, orientation, and activation
disabledon aTab. The tab can't be selected. The APG allows two choices for keyboard focus: skip it or let it receive focus witharia-disabled="true". We keep it focusable, because a screen reader user who can't land on it never learns it exists or why it's off. Pair it with a visible reason, like a tooltip saying "Q&A opens after launch".orientationonTabList."horizontal"(default) or"vertical". It setsaria-orientationand decides which arrow keys move focus.activationonTabs."automatic"(default) selects a tab as soon as it gets focus;"manual"waits for Enter or Space. Chapter 4 explains when to pick each.- What if
defaultValuepoints at a disabled or missing tab? Fall back to the first enabled tab and warn in development. A page should never render with no panel shown.
Tab and its TabPanel can sit anywhere under Tabs. They are paired by their shared string value, not by position.How does a Tab know its neighbors when the user presses the right arrow? The simplest reliable way: at keydown time, ask the tab list element for its [role="tab"] children in DOM order. That order is always what the user sees, even with conditional tabs or tabs wrapped in tooltips.
Registering tabs into an array through context also works but must handle mount order, reordering, and Strict Mode double effects. Reading the DOM on a key press costs microseconds for 11 tabs.
Check your understanding · Exercise 2
A product page keeps the selected tab in ?tab= and passes it as value. Which statements are true? Select all that apply.
Roles, ids, and the keyboard
Screen reader users have met tabs thousands of times. When the markup follows the WAI-ARIA tabs pattern, their screen reader says something like "Reviews, tab, selected, 2 of 4" and they know the keys without reading anything. The ARIA patterns lesson covers roles and states in general; this chapter is the exact contract for tabs.
Three roles and the links between them
role="tablist"on the row. It needs a name:aria-label="Product details", oraria-labelledbypointing at a visible heading. Addaria-orientation="vertical"for vertical tabs (horizontal is the default).role="tab"on each button, witharia-selected="true"on the selected one and"false"on the rest.aria-controlspoints at the id of its panel. Use a realbuttonelement so clicks, Enter and Space work for free.role="tabpanel"on each panel, witharia-labelledbypointing back at its tab, so the panel is announced as "Reviews, tab panel". Hidden panels get thehiddenattribute (chapter 5 covers the options).tabindex="0"on the panel when its content has no focusable element at the start, like a block of text. Without it, pressing Tab from the tab list jumps past the whole panel to whatever comes next. If the panel starts with a link or a button, leave the panel itself out of the tab order.
aria-controls points down and aria-labelledby points back. Only the selected tab has tabindex="0"; the prefix t1 comes from useId, so it matches on server and client.<div role="tablist" aria-label="Product details">
<button role="tab" id="t1-tab-overview" aria-selected="false"
aria-controls="t1-panel-overview" tabindex="-1">Overview</button>
<button role="tab" id="t1-tab-reviews" aria-selected="true"
aria-controls="t1-panel-reviews" tabindex="0">Reviews</button>
<button role="tab" id="t1-tab-qa" aria-selected="false"
aria-disabled="true" aria-controls="t1-panel-qa" tabindex="-1">Q&A</button>
</div>
<div role="tabpanel" id="t1-panel-overview"
aria-labelledby="t1-tab-overview" tabindex="0" hidden>...</div>
<div role="tabpanel" id="t1-panel-reviews"
aria-labelledby="t1-tab-reviews" tabindex="0">...</div>
One tab stop for the whole row
A tab list is a composite widget: one stop in the Tab order, with arrow keys to move inside it. The keyboard navigation lesson explains the two ways to build one. Roving tabindex gives the current tab tabindex="0" and every other tab tabindex="-1", then moves real DOM focus with element.focus() on each arrow key. aria-activedescendant keeps focus on the container and points at the active child by id.
For tabs, roving tabindex is the clear pick: each tab is a real button that can hold focus, the browser scrolls it into view and draws the focus ring, and it's what the APG examples use.
| Key | Horizontal tabs | Vertical tabs |
|---|---|---|
| Tab | Moves into the tab list, landing on the selected tab. Pressing it again moves to the panel. | Same. |
| Right Arrow / Left Arrow | Next / previous tab, wrapping at the ends. Flipped in right-to-left layouts. | Not used (or same as Down / Up, if you want to be forgiving). |
| Down Arrow / Up Arrow | Not used, so the page can still scroll. | Next / previous tab, wrapping at the ends. |
| Home / End | First / last tab. Marked optional in the APG, but users expect them. | Same. |
| Enter / Space | Selects the focused tab in manual mode. In automatic mode it's already selected. | Same. |
| Delete | Optional in the APG, only for tabs that can be closed. Out of scope here. | Same. |
Arrow keys land on disabled tabs too (we chose to keep them focusable), but never select them. In automatic mode, focus moves to the disabled tab and the selection stays where it was, so the panel never goes blank.
Automatic or manual activation
Decision Should moving focus to a tab also select it?
What we pick: Automatic by default, because most panels are already loaded or cheap. Pages whose panels load slowly opt into activation="manual". With prefetch on focus (chapter 6), manual mode can even start the request while the user is still deciding, so pressing Enter often shows the panel at once.
Right to left. In Arabic and Hebrew, the page sets dir="rtl" and the tab row is mirrored: the first tab is on the right. Right Arrow must still move to the tab that's visually to the right, which is now the previous one in DOM order.
So the key handler reads the direction (getComputedStyle(tablist).direction, or a direction value from your i18n provider) and swaps Left and Right. Up and Down never flip. Libraries such as Radix (through a dir prop) and React Aria (through the locale) do this for you, and forgetting it is a common bug in hand-built tabs.
focus(), and Enter or Space sends onValueChange. Either way the page, not the component, writes the URL.Ids that match on the server and the client
aria-controls and aria-labelledby need ids, and ids must be unique on the page: two tab sets both using tab-overview break each other. The tempting fix is a random number or a global counter. With server rendering, that fails: the server renders tab-0.42 and the browser computes tab-0.87 during hydration, so React reports a hydration mismatch.
React doesn't promise to patch mismatched attributes, so the DOM can keep the server ids while anything rendered later on the client, like a lazily mounted panel, uses the client ids. The tab now points at a panel id that doesn't exist. The server-side rendering lesson explains hydration and why the server and client trees must match.
React's useId solves this: it builds the id from the component's position in the tree, so the server and the client get the same string. Tabs calls useId once and shares the base through context; each part adds a suffix like -tab-reviews. Two rules: let callers pass their own id to override it, and never parse the generated string or use it in a CSS selector, because its format has changed between React versions. If values can contain spaces, encode them before using them in an id.
Check your understanding · Exercise 3
A tester reports bugs in a tabs component. Which are real bugs against the tabs pattern? Select all that apply.
What happens to the panels you're not looking at
Only one panel is visible, but the others still exist somewhere: in the DOM, in React's memory, or nowhere at all. This choice decides whether a half-typed form survives a tab switch, how big the DOM is, how many requests fire on page load, and whether find-in-page can see the hidden text. It's the decision that separates a component that demos well from one that works on a real product page.
Take the product page. Overview is about 150 DOM nodes. Reviews shows 50 reviews at about 40 nodes each, so 2,000 nodes, plus a filter form.
Specs is a 60-row table, about 400 nodes, loaded from an API. Q&A is another 1,500 nodes, also from an API. Rendering all four up front means about 4,000 nodes and three API calls before the user has looked at anything but Overview.
Decision When should a panel be mounted, and when should it be removed?
What we pick: Make it a per-panel prop, because panels differ. The default is mount="lazy", which means mount on first visit and keep. Cheap panels whose text should be in the server HTML, like Overview on a product page, use mount="eager". Panels with live effects, like a video or a chart that polls every 5 seconds, use mount="unmount" and lift any state worth keeping into the page.
How to hide a mounted panel
| Technique | Visible | In the accessibility tree | Focusable | Rendering cost while hidden | Use it when |
|---|---|---|---|---|---|
hidden attribute | No | No | No | Almost none: no layout or paint | The default for inactive panels. |
hidden="until-found" | No | No | No | Almost none: contents skipped, but the panel's own box stays in layout | You want find-in-page and links to text fragments to reveal a hidden panel. |
inert (still visible) | Yes | No | No | Full | A panel is sliding out during a transition and must not take clicks or focus. |
content-visibility: hidden (CSS) | No (its box stays) | No | No | Skips its contents but keeps their rendering state | A huge panel that should come back faster than a full layout. |
Off-screen with opacity: 0 | No | Yes | Yes | Full | Never. Screen reader and keyboard users reach content they can't see. |
The hidden attribute is the right default: it's the same as display: none, so the panel takes no layout, no paint, and is removed from the accessibility tree and the tab order. One trap: a CSS rule like .panel { display: grid } overrides it, because the browser's style for hidden is only a default. Add [hidden]:not([hidden="until-found"]) { display: none !important } to the design system's base CSS. The :not() part matters: a plain [hidden] rule would also break the until-found value below, which hides content in a different way.
hidden="until-found" is a newer value that keeps the content hidden but lets the browser search it. When find-in-page, a link to a text fragment, or a link to an id inside it matches, the browser fires a beforematch event on the element, removes the attribute, and scrolls to the match. Browsers draw it with content-visibility: hidden rather than display: none, so the panel's own box, padding, and border stay in the layout: put padding on an inner wrapper.
Support arrived in Chromium browsers in 2022 (version 102), Firefox in 2025 (version 139), and Safari at the end of 2025 (version 26.2). MDN notes that Firefox before 148 and Safari 26.2 don't scroll to the match correctly. Older browsers treat any value of hidden as plain hidden, so it fails safely: treat it as an upgrade, not something you depend on.
For tabs, there's one extra step: in the beforematch handler, select that panel's tab, or the panel shows while the tab list still highlights a different tab. In React, attach that listener with addEventListener in an effect.
React adds one more catch: it treats hidden as a plain boolean, so hidden="until-found" in JSX comes out as hidden="" (React issue 24740, still open in 19.x). Render hidden as a boolean, then set the value with setAttribute("hidden", "until-found") in a layout effect, which runs before paint.
The server HTML still has plain hidden, so find-in-page and links can't reveal a panel until hydration. If that matters, a few lines of inline script right after the tabs' HTML can set the value during parsing. Hydration accepts it, because React only checks that a boolean attribute is present.
Kept-mounted panels keep running. A chart that polls every 5 seconds in a hidden panel still sends 12 requests a minute. Give each panel an isActive value from context so it can pause polling, videos, and animations.
React 19.2 (October 2025) added an Activity component for exactly this case: with mode="hidden" it hides the subtree with display: none, keeps its state and DOM, and cleans up its effects until it's visible again. On React 19.2 or later, the kept-mounted mode can wrap each inactive panel in it.
Check your understanding · Exercise 4
The Reviews panel holds a filter form and a list of 50 reviews. Users often switch to Specs and back. Which choices keep their filters without paying for every panel on page load? Select all that apply.
The URL and the data behind each tab
Marketing shares shop.example/p/123?tab=reviews in an email. A support agent asks a user to "open the Billing tab and read me the number". A user refreshes the page. In all three cases the right tab must open, so the selected tab belongs in the URL.
The URL as state lesson covers the general idea; here we decide where in the URL, and how it interacts with history and data loading.
Decision Where in the URL should the selected tab live?
What we pick: A query parameter for tabs that switch views within one page, like a product page. A route segment when each tab is really a separate page, like big settings sections, and then use navigation links instead of tab roles. Avoid the hash when you render on the server.
pushState or replaceState
history.pushState adds a history entry, so Back returns to the previous tab. history.replaceState changes the current entry, so Back leaves the page. With automatic activation, a keyboard user who arrows across 4 tabs and then presses Back would, with push, step back through 4 tabs before leaving the page.
For view tabs, use replace: the URL is always shareable and refresh works, but the history isn't filled with tab visits. Use push only for route-segment tabs that feel like pages. Whichever you choose, listen for popstate (or use the router's search params) so Back and Forward update the selected tab: in controlled mode that happens on its own, because the URL is the owner.
function ProductTabs() {
const [params, setParams] = useSearchParams();
const raw = params.get("tab");
const tab = TABS.includes(raw) ? raw : "overview";
const onValueChange = (next: string) => {
const p = new URLSearchParams(params);
if (next === "overview") p.delete("tab"); else p.set("tab", next);
setParams(p, { replace: true });
};
return (
<Tabs value={tab} onValueChange={onValueChange}>...</Tabs>
);
}
Loading data per tab
The naive version keeps one data variable for "the current tab" and calls fetch in an effect whenever the tab changes. Here's how that breaks: the user clicks Reviews, which starts a 900 ms request, and 200 ms later clicks Specs, which starts a 150 ms request. Specs arrives at 350 ms and renders. Reviews arrives at 900 ms and overwrites data, so the Specs panel now shows reviews.
This is a request race, and the request races lesson covers the general fixes. For tabs, a few changes make it go away.
- One cache entry per tab. Each panel owns its query, keyed by what it shows, like
["reviews", productId]and["specs", productId], in a server-state cache such as TanStack Query. A late Reviews response writes into the Reviews entry and can never land in the Specs panel. - Show cached data at once, refresh in the background. Coming back to Reviews shows the data from 30 seconds ago immediately, then refetches if it's older than its
staleTime. That's stale-while-revalidate: no spinner on the second visit. - Cancel only what can't be reused. Don't abort the Reviews request when the user leaves; it fills the cache for the next visit. Do abort requests whose parameters changed, like a filter typed into the Reviews panel, with an
AbortController. - Keep the previous data while a parameter changes. When the Reviews filter changes from 5 stars to 4 stars, keep showing the 5-star list, dimmed, until the 4-star list arrives, instead of flashing an empty spinner.
Prefetch on intent. A mouse user's pointer often rests on a tab for a few hundred milliseconds before the click lands. Start the panel's query on pointerenter and on focus; by the time the click arrives, a 200 ms request is mostly done. On touch screens there's no hover, so prefetch the next most likely tab when the browser is idle, or prefetch everything small after the page loads.
Prefetching uses the same cache keys, so a prefetch and a real load never fetch twice. The panel's code can be prefetched the same way, if each heavy panel is a separate chunk loaded with import().
Check your understanding · Exercise 5
Product tabs use ?tab= with automatic activation, and Reviews and Specs fetch their own data. Which designs behave well? Select all that apply.
The sliding indicator and tabs that don't fit
The animated selection indicator
Designers often want the underline under the selected tab to slide to the new tab instead of jumping. The simplest version, a border-bottom on the selected tab, can't slide, because each tab draws its own line. So the indicator becomes one separate element inside the tab list, positioned under whichever tab is selected.
To place it, measure the selected tab: its offsetLeft and offsetWidth relative to the tab list. Then move the indicator with transform: translateX(...) and size it with scaleX(...) on a bar that's 1 pixel wide at rest.
Why not animate left and width? Those are layout properties, so every frame of the animation runs layout. A CSS transition on transform can run on the compositor, so the slide stays smooth even if the main thread is busy rendering the new panel, which it usually is at that exact moment.
// The list has position: relative, and the bar sits inside it
// with transform-origin: left, so it scrolls along with the tabs.
function placeIndicator(list: HTMLElement, bar: HTMLElement) {
const tab = list.querySelector<HTMLElement>('[aria-selected="true"]');
if (!tab) return;
const x = tab.offsetLeft;
bar.style.transform = `translateX(${x}px) scaleX(${tab.offsetWidth})`;
}
const ro = new ResizeObserver(() => placeIndicator(list, bar));
list.querySelectorAll('[role="tab"]').forEach((t) => ro.observe(t));
ro.observe(list);
Measuring once on selection is not enough. Tab widths change when the web font finishes loading (the fallback font is narrower or wider), when the language changes, when a badge count goes from 9 to 10, and when the window resizes. A ResizeObserver on each tab and on the list catches all of these.
For vertical tabs, measure offsetTop and offsetHeight and use translateY and scaleY. In right-to-left layouts, offsetLeft is still measured from the left edge, so the same math works.
- Reduced motion. Wrap the transition in
@media (prefers-reduced-motion: no-preference), so users who asked for less motion get an instant jump. The visual accessibility lesson covers why sliding motion can make some people feel ill. - Don't rely on the indicator alone. Selected tabs benefit from a second cue, like bold text or a stronger color, with at least 3:1 contrast for the indicator against its background. Forced colors mode (Windows High Contrast) replaces background colors and removes shadows, so a bar drawn with
background-colorcan vanish: draw it with a border, or give it theHighlightsystem color inside@media (forced-colors: active). - Server rendering. Measurement only works in the browser. Render the first position with plain CSS (a border on the selected tab), then switch to the sliding bar after hydration, so there's no missing indicator in the first paint.
- A CSS-only future. CSS anchor positioning can tie the bar to the selected tab with no JavaScript measuring. It reached the last major engine only in early 2026 (Firefox 147), so treat it as an upgrade behind
@supports, not the base, until your users' browsers catch up.
transform, which the compositor can animate without running layout each frame.When the tabs don't fit
Five tabs averaging 100 pixels need 500 pixels. A phone in portrait gives you about 360 pixels minus padding, and German labels are often 30% longer than English ones. You can't just let the row wrap onto two lines: it looks like two separate tab lists and the indicator has nowhere to sit.
Decision What should the tab list do when its tabs don't fit?
What we pick: Horizontal scroll with snap, an edge fade, and scroll-into-view of the selected tab is the default: one pattern everywhere, and cheap. Offer the "More" menu only for dense toolbars. Switch to an accordion for settings-style pages where the panels are long forms that users fill in one by one.
Check your understanding · Exercise 6
A team reports problems with their tabs. Which fixes are right? Select all that apply.
Architecture, state, and the public interface
Now put the decisions together. The key boundary: the tabs component knows nothing about the URL, the router, or the API. It owns selection, focus, ARIA, and mounting.
The page owns where the value is stored and what each panel loads. That keeps the component small, testable without a server, and usable on pages that don't route at all, like a docs code sample.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
Tabs (root) | Holds the selected value (controlled or not), the base id from useId, activation, the set of visited panels, and shares them through context. | One owner of selection for every part, wherever they sit in the tree. |
TabList | Renders role="tablist", handles arrow keys, Home and End, reads direction, scrolls the selected tab into view, hosts the indicator. | All keyboard logic in one place; tabs stay simple buttons. |
Tab | Renders a button with role="tab", aria-selected, aria-controls, roving tabindex, and reports intent (hover or focus). | Each tab is a real button, so clicks and focus work natively. |
TabPanel | Renders role="tabpanel", decides mounted or not from mount and the visited set, applies hidden, provides isActive. | Mounting is per panel, because panels differ in cost. |
Indicator | Measures the selected tab with ResizeObserver and moves with transform. Optional and separately imported. | Pages that don't animate don't pay for it. |
| URL adapter (page) | Reads ?tab=, validates it, passes value, writes changes with replace. | Different apps use different routers; the component stays router-free. |
| Query cache (page) | One entry per panel's data, prefetch on intent, stale-while-revalidate. | Data fetching already has a home in the app; the component only reports intent. |
value, reports changes and intent, and the page decides what to store and what to load.?tab=. After that, every panel reads its own cache entry, and only a tab change writes back, to the URL, with replace.The client data model
A tabs component has little data, but each piece has a clear home, and putting one in the wrong place causes the bugs we've seen. The selected value lives in the URL (controlled) or in Tabs state (uncontrolled). The focused tab is not React state at all: it's wherever DOM focus is, and storing a copy only creates a second truth that drifts.
The set of visited panels lives in the root, so lazy panels know they've been opened. Panel data lives in the query cache, keyed per panel. Form state inside a panel lives in that panel if it's kept mounted, or above Tabs if it must survive unmounting.
type Orientation = "horizontal" | "vertical";
type Activation = "automatic" | "manual";
type MountMode = "eager" | "lazy" | "unmount";
type TabsContextValue = {
baseId: string; // from useId(), or the caller's id
selected: string; // the one source of truth for selection
select: (value: string, reason: ChangeReason) => void;
orientation: Orientation;
activation: Activation;
visited: ReadonlySet<string>; // panels opened at least once
reportIntent?: (value: string) => void; // hover or focus, for prefetch
};
type ChangeReason = "click" | "keyboard" | "find-in-page";
const tabId = (base: string, v: string) => `${base}-tab-${encodeURIComponent(v)}`;
const panelId = (base: string, v: string) => `${base}-panel-${encodeURIComponent(v)}`;
The public interface
| Part | Prop or event | Type and default | Notes |
|---|---|---|---|
Tabs | value / defaultValue | string | Pass value for controlled mode, defaultValue for uncontrolled. Not both. |
Tabs | onValueChange | (value, { reason }) | Fires on every requested change. reason helps analytics and lets the page use push for clicks only, if it wants. |
Tabs | activation | "automatic" (default) or "manual" | Manual for panels that load slowly. |
Tabs | onTabIntent | (value) => void | Called on hover and focus; the page prefetches. |
TabList | orientation | "horizontal" (default) or "vertical" | Sets aria-orientation and the arrow keys. |
TabList | aria-label or aria-labelledby | string, required | A tab list needs a name. Warn in development if missing. |
Tab | value, disabled | string, boolean | Disabled tabs stay focusable with aria-disabled. |
TabPanel | value, mount | string, "lazy" (default), "eager", or "unmount" | Per-panel mounting from chapter 5. |
TabPanel | searchable | boolean, false | Uses hidden="until-found" where supported (set through the DOM, since React writes hidden as a boolean), and selects the tab on beforematch. |
Check your understanding · Exercise 7
A teammate proposes changes to this architecture. Which ones keep the boundaries sound? Select all that apply.
Tabs or links, and how to test it all
When a tab bar should be links
Look at the bar at the top of a GitHub repository: Code, Issues, Pull requests, Actions. It looks like tabs. But each item goes to a different URL with its own page, and you can middle-click one to open it in a new browser tab. That's navigation, and it's built as links inside a nav, with aria-current="page" on the current one.
If it used the tabs pattern instead, three things would break.
- The keyboard model lies. A screen reader says "tab, 1 of 4", so the user presses Right Arrow and expects a panel to change in place. Instead, a whole page loads and focus resets to the top.
- Links stop being links. Buttons can't be opened in a new browser tab, copied as a link, or shown in the browser's status bar on hover.
- There's no panel.
aria-controlsmust point at a tab panel on the same page. After a full navigation, the old tab list is gone.
The rule: if choosing an item changes the path to a different page, or users might want to open it in a new browser tab, use links with aria-current="page", styled however the designers like. If choosing an item swaps content within the same page and the user stays in place, use the tabs pattern. The ?tab= query parameter from chapter 6 doesn't change this: the page and its focus stay put, so it's still tabs.
<nav aria-label="Repository">
<a href="/acme/app" aria-current="page">Code</a>
<a href="/acme/app/issues">Issues</a>
<a href="/acme/app/pulls">Pull requests</a>
</nav>
Think first
A settings area has General, Billing, and Team at /settings/general, /settings/billing and /settings/team, with the list on the left and the form on the right. The page doesn't fully reload: the router swaps only the right side. Tabs or links?
Links. Each section has its own path, users bookmark and share them, and someone may want Billing open in a second browser tab. The fact that the router swaps only part of the page is an implementation detail; for the user these are separate pages.
Use a nav with aria-current="page", and after each route change, move focus to the new section's heading so screen reader users know something changed. The focus management lesson covers that pattern for client-side routing.
Testing
Most of a tabs component's value is behavior: roles, focus, keys, and mounting. So most tests should drive it the way users do, through roles and the keyboard, not by checking class names.
test("arrow keys move focus and select in automatic mode", async () => {
const user = userEvent.setup();
render(<ProductTabs />);
await user.tab();
expect(screen.getByRole("tab", { name: "Overview" })).toHaveFocus();
await user.keyboard("{ArrowRight}");
const reviews = screen.getByRole("tab", { name: /Reviews/ });
expect(reviews).toHaveFocus();
expect(reviews).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: /Reviews/ })).toBeVisible();
await user.keyboard("{End}");
expect(screen.getByRole("tab", { name: "Q&A" })).toHaveFocus();
expect(reviews).toHaveAttribute("aria-selected", "true"); // Q&A is disabled
});
| Layer | What to test | Tool |
|---|---|---|
| Unit and integration | Roles and links between ids, roving tabindex, every key in both orientations and in dir="rtl", manual vs automatic, disabled tabs, controlled and uncontrolled, fallback for unknown values, mount modes keeping state. | Vitest or Jest with Testing Library and user-event |
| Automated accessibility | No rule violations in every story: missing names, broken aria-controls, contrast. | axe-core in unit tests and in Storybook |
| Server rendering | Render to a string, hydrate, and fail the test on any hydration warning. Two tab sets on one page have unique ids. | renderToString plus hydrateRoot in a test |
| End to end | Deep link opens the right tab, Back leaves the page with replace, the selected tab scrolls into view on a 360 pixel viewport, find-in-page reveals a searchable panel where supported. | Playwright across Chromium, Firefox, and WebKit |
| Visual | Indicator position after font load, in RTL, and with reduced motion. | Screenshot tests |
| Manual screen reader | Announcements of role, name, state, and position. | NVDA with Firefox or Chrome, VoiceOver with Safari on macOS and iOS, TalkBack with Chrome |
Check your understanding · Exercise 8
Which of these should be built with the tabs pattern (tablist, tab, tabpanel) rather than navigation links? Select all that apply.
Running it in the interview
Tabs is usually a 30 minute question, sometimes the warm-up to a bigger one. The risk isn't running out of ideas; it's spending 20 minutes on CSS. Here's one way to spend the time.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 4 | Ask the scope questions from chapter 1. Write down: number of tabs, panel content, URL, SSR, phones, RTL. | "Is this in-page content switching, or navigation between pages? That decides the whole pattern." |
| 4 to 9 | Sketch the wireframe and the compound API with string values. Mention controlled and uncontrolled. | "The page may own the value through the URL, so the component must accept value and onValueChange." |
| 9 to 16 | Draw the ARIA wiring and list the keys. Pick an activation mode and justify it. | "One tab stop, roving tabindex, automatic activation unless panels are slow." |
| 16 to 22 | Mounting strategy with the numbers, then URL and data per tab. | "Lazy then keep by default, per panel. ?tab= with replace. One cache entry per panel, so no races." |
| 22 to 27 | Small screens, the indicator, RTL. | "Scroll with snap and scroll-into-view; transform for the indicator; arrow keys flip in RTL." |
| 27 to 30 | Testing, and the tabs vs links rule. | "If each item is its own page, I'd use links with aria-current." |
Draw three things, in this order: the wireframe with the parts named, the ARIA wiring, and the architecture with the boundary between the component and the page. Everything else can be said out loud. When you make a choice, say the alternative you rejected and the number that decided it, for example "lazy then keep, because eager costs about 4,000 nodes and three requests that most visitors never use". That sentence is worth more than any amount of extra detail.
Follow-up questions to expect
Think first
"Which tabs a user sees depends on their permissions, loaded from an API. How does your component handle tabs that appear after the first render?"
Because values are strings and the keyboard reads tabs from the DOM at key press time, new tabs just work: no index shifts. The edge cases are the selected value and layout. If the URL says ?tab=billing and Billing isn't loaded yet, keep the value but render a panel skeleton instead of falling back too early; fall back to the first tab only once permissions arrive and Billing is really missing.
To avoid a layout shift when tabs appear, reserve the tab list's height from the start, and render known tabs on the server when you can.
Think first
"The Profile tab has unsaved edits. What happens when the user clicks Security?"
Two choices. Keep the Profile panel mounted (mount="lazy"), so the edits are still there when they come back: no dialog needed, and usually the kindest answer. Or, if leaving should be confirmed, use controlled mode: onValueChange asks the page, and the page can show a confirm dialog before updating the value. That's exactly why controlled mode exists.
Also guard the real exits, like closing the browser tab, with a beforeunload handler while the form is dirty.
Think first
"Switching to the Analytics tab takes 400 ms and INP is poor. What do you do?"
First find where the time goes with a performance trace. Usually it's rendering a heavy chart synchronously in the click handler. Update the selected tab first so the tab and indicator respond in the next frame, then render the heavy panel as a lower priority update with startTransition, showing a skeleton meanwhile. Prefetch the panel's code and data on hover or focus.
If users switch back and forth, keep the panel mounted so the second visit costs only a style change. And check that hidden panels aren't still doing work, like polling or animating.
Check your understanding · Exercise 9
You have 30 minutes. Which moves show senior judgment? Select all that apply.
Summary
- Use compound components (
Tabs,TabList,Tab,TabPanel) with string values, and support both controlled and uncontrolled modes so the page can own the value. - Follow the WAI-ARIA tabs pattern exactly: named
tablist,tabwitharia-selectedandaria-controls,tabpanelwitharia-labelledby, andtabindex="0"on panels without focusable content. - One tab stop with roving tabindex; arrows, Home and End inside; Up and Down for vertical; Left and Right flip in right-to-left layouts.
- Automatic activation when panels are instant, manual when showing a panel is slow. Prefetch on focus makes manual feel fast.
- Build ids from
useIdso server and client agree, and never parse them. - Mounting is a per-panel choice: lazy then keep by default, eager for cheap content that should be in the HTML, unmount for panels with live effects. Hide with
hidden, and pause work in hidden panels. - Put view tabs in
?tab=with replace; give each panel its own cache entry to rule out request races; prefetch on hover and focus. - Animate the indicator with
transform, re-measure withResizeObserver, and respect reduced motion. On small screens, scroll with snap and keep the selected tab in view. - If each item is its own page, it's navigation: use links with
aria-current="page", not tabs.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA Authoring Practices: Tabs pattern The roles, states, and keyboard model, with examples for automatic and manual activation.
- WAI-ARIA APG: Developing a keyboard interface Roving tabindex, aria-activedescendant, and focusable disabled controls.
- MDN: ARIA tab role Reference for
role="tab"and its required attributes. - MDN: the hidden global attribute Includes the
until-foundstate and thebeforematchevent. - React issue 24740: the hidden attribute does not accept string values Why
hidden="until-found"in JSX renders ashidden="". - React docs: useId Why ids must come from the tree position when you render on the server.
- React docs: Activity Hiding a subtree while keeping its state and pausing its effects, added in React 19.2.
- MDN: aria-current Marking the current page in a set of navigation links.
- MDN: ResizeObserver Watching element sizes for the indicator.
- MDN: History API
pushState,replaceState, and thepopstateevent. - web.dev: Interaction to Next Paint The responsiveness metric that a slow tab switch hurts.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it. - Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.