cf.completefrontend
My progress
SYSTEM DESIGN · #3

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.

QuestionAnswer we assumeWhat 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.
Wireframe of the product page tabs. A tab list holds four tabs: Overview, Reviews with a badge of 212, which is selected and has the indicator bar under it, Specs, and Q&A, which is disabled. Below is the Reviews tab panel, with a filter form set to 4 stars and up and three review rows. Labels on the right name the TabList, the indicator, and the TabPanel.shop.example/p/123?tab=reviewsOverviewReviews 212SpecsQ&A4+ starsfilter formTabListindicatorTabPanelreviews, lazythen kept
Four parts to design: the tab list (one tab stop), each tab (a real button), the sliding indicator, and the panel. The disabled Q&A tab is still visible, so users know it exists.

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.

tsx
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>
  );
}
The naive version: an index in state and a row of buttons.
  • A screen reader hears six unrelated buttons. Nothing says they're tabs, which one is selected, or which content they control. The active class 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 2 in 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:

ForceWhat it meansChapter
Many owners of one valueThe 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 contractRoles, states, links between tab and panel, and a specific keyboard model that screen reader users already know.4
Panels with weight and memoryPanels can cost hundreds of DOM nodes and network requests, and can hold state the user cares about.5
Data that arrives latePanels that fetch can show stale or out-of-order data when the user clicks fast.6
Space that runs outLabels 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 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?

One component with a config array

Tabs items={[{ value, label, content }]}.

The component renders the tab list and panels from the array.

Pros

  • Short to write for simple cases.
  • Easy to generate from data, like a CMS list.

Cons

  • Every new need becomes a prop: icons, badges, a disabled reason, a tooltip, a custom panel wrapper. After a year it has 25 props.
  • Content becomes a value in an array, so panels lose normal JSX structure and are hard to lazy load.

Compound components

Tabs, TabList, Tab, and TabPanel, written as JSX like plain HTML.

Tabs holds the state and shares it with its children through React context.

Pros

  • Each part takes its own props, so a badge on one tab is just JSX inside that tab.
  • Matches how the markup really looks, which makes the ARIA wiring easy to explain.
  • The pattern used by Radix, Reach UI, Headless UI, and React Aria, so engineers already know it.

Cons

  • Slightly more to type for the simplest case.
  • Parts only work inside Tabs; you need a clear error when someone forgets.

A headless hook

useTabs() returns prop getters like getTabProps(value) and getPanelProps(value).

The caller writes all the markup.

Pros

  • Total control over markup and styling.
  • Good for teams that build very custom tab bars.

Cons

  • Every team can now get the markup wrong, which is what the design system exists to prevent.
  • More code at every call site.

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.

tsx
<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>
How a product page uses it. Values are strings, not positions.

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.

ts
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;
}
One hook gives both modes. The parent wins whenever it passes a value.

Disabled tabs, orientation, and activation

  • disabled on a Tab. The tab can't be selected. The APG allows two choices for keyboard focus: skip it or let it receive focus with aria-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".
  • orientation on TabList. "horizontal" (default) or "vertical". It sets aria-orientation and decides which arrow keys move focus.
  • activation on Tabs. "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 defaultValue points 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.
The compound component tree. The Tabs root holds the selected value, the base id, and the visited set, and shares them through context. Below it, the TabList handles keys and roving focus and contains three Tab buttons: overview, reviews, which is selected, and specs. Beside it, TabPanel elements decide mounting and hiding. Tabs ask the root to select them through context.Tabs (root)value, baseId, visitedTabListkeys, roving focusTabPanel x 3mount, hiddencontextcontextoverviewreviewsspecsTab buttons, read in DOM orderpairs by value:tab reviews to panel
Every part reads the same context, so a 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", or aria-labelledby pointing at a visible heading. Add aria-orientation="vertical" for vertical tabs (horizontal is the default).
  • role="tab" on each button, with aria-selected="true" on the selected one and "false" on the rest. aria-controls points at the id of its panel. Use a real button element so clicks, Enter and Space work for free.
  • role="tabpanel" on each panel, with aria-labelledby pointing back at its tab, so the panel is announced as "Reviews, tab panel". Hidden panels get the hidden attribute (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 wiring. A tablist named Product details holds three tab buttons: Overview with tabindex -1, Reviews, selected, with tabindex 0 and id t1-tab-reviews, and Specs with tabindex -1. The Reviews tab's aria-controls points down to the panel with id t1-panel-reviews, role tabpanel, and tabindex 0. The panel's aria-labelledby points back up to the Reviews tab. The t1 prefix comes from useId.tablist: Product detailsOverviewtabindex -1Reviewstabindex 0Specstabindex -1t1-panel-reviewsrole tabpanel, tabindex 0aria-controlsaria-labelledbyid t1-tab-reviewsaria-selected trueother panels:hidden
Two ids tie each pair: 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.
html
<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&amp;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>
What the component renders for the product page, with the Reviews tab selected.

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.

KeyHorizontal tabsVertical tabs
TabMoves into the tab list, landing on the selected tab. Pressing it again moves to the panel.Same.
Right Arrow / Left ArrowNext / 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 ArrowNot used, so the page can still scroll.Next / previous tab, wrapping at the ends.
Home / EndFirst / last tab. Marked optional in the APG, but users expect them.Same.
Enter / SpaceSelects the focused tab in manual mode. In automatic mode it's already selected.Same.
DeleteOptional 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?

Automatic activation

Arrow key moves focus and selects the tab in the same step.

The panel changes as you arrow along.

Pros

  • One key press to see a panel.
  • What most users expect from desktop apps.
  • The APG's recommended default when panels show instantly.

Cons

  • Arrowing from tab 1 to tab 5 selects tabs 2, 3 and 4 on the way. If each one fetches or renders a heavy chart, that's three wasted loads and a laggy keyboard.
  • If selection writes to the URL with pushState, it fills the history with every tab you passed.

Manual activation

Arrow keys only move focus.

Enter or Space selects the focused tab.

Pros

  • No work until the user decides.
  • Good when showing a panel takes noticeable time, such as a network request or a heavy render.

Cons

  • One extra key press, and some users don't realize they must press Enter.
  • Two visual states to design: focused but not selected, and selected.

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.

Activation
Layout
Overview panel: plain text, already in the HTML.
Five tabs, horizontal, left to right, automatic activation. Overview is selected with tabindex 0. Focus is on Overview. Q&A is disabled. Next tab: Right Arrow. Previous tab: Left Arrow. Requests fired: 0.Overviewtabindex 0Reviewstabindex -1Specstabindex -1Q&Atabindex -1Shippingtabindex -1Overview panelnext: Right Arrowprevious: Left ArrowHome, End: endsactivation: automaticrequests: 0focus: Overview
Click Overview, then try the arrow keys, Home, and End.

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.

Sequence for pressing Right Arrow on the Reviews tab in automatic mode, with four columns: User, TabList, Page URL, and Panel. The user presses Right Arrow. The TabList focuses Specs and moves tabindex, then calls onValueChange with specs. The page replaces the URL with tab=specs and passes value specs back. Then the Specs panel is unhidden, or mounted on its first visit. The Specs panel paints for the user. In manual mode, the flow stops after focus until the user presses Enter or Space.UserTabListPage URLPanelRight Arrowfocus(Specs)onValueChangevalue = specsunhide or mountSpecs paintsmanual: stop after focus until Enter
In automatic mode one key press runs the whole chain. In manual mode it stops after 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?

Only the selected panel (unmount the rest)

Render the selected panel; others render null.

Switching tabs unmounts the old panel and mounts the new one.

Pros

  • Smallest DOM and memory.
  • Hidden panels can't run timers, polls, or effects.

Cons

  • All state in a panel is lost on switch: form input, scroll position, an expanded row.
  • Coming back means remounting, and maybe refetching, every time.
  • Hidden text is invisible to find-in-page and to the server-rendered HTML.

Eager: mount every panel, hide the inactive ones

Render all panels; inactive ones get the hidden attribute.

Switching just flips attributes.

Pros

  • Instant switching, with all state preserved.
  • All content is in the server HTML, which helps search engines and no-JavaScript readers.

Cons

  • Pays the full cost up front: 4,000 nodes and every panel's requests, even for tabs nobody opens.
  • Hidden panels keep running their effects, like polling or video players.

Lazy, then keep: mount on first visit, hide afterwards

A panel mounts the first time its tab is selected.

After that it stays mounted and is hidden when inactive.

Pros

  • Pay only for tabs the user opens.
  • State survives switching back and forth.
  • Common in libraries as an option, for example Chakra UI's lazy mounting without unmount on exit.

Cons

  • The first visit to a tab is slower than eager.
  • Memory grows with each tab visited, which matters for dashboards with 11 heavy tabs.

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.

Mount mode
Show
Mount mode lazy. Overview is visible. Mounted panels: overview. About 150 DOM nodes, 0 requests so far. Reviews filter: empty.Overviewvisible150 nodesReviewsnot mounted2000 nodesSpecsnot mounted400 nodesQ&Anot mounted1500 nodesDOM nodes: about 150requests: 0Reviews filter:empty
Open Reviews, type in its filter, switch to Specs, then come back.

How to hide a mounted panel

TechniqueVisibleIn the accessibility treeFocusableRendering cost while hiddenUse it when
hidden attributeNoNoNoAlmost none: no layout or paintThe default for inactive panels.
hidden="until-found"NoNoNoAlmost none: contents skipped, but the panel's own box stays in layoutYou want find-in-page and links to text fragments to reveal a hidden panel.
inert (still visible)YesNoNoFullA panel is sliding out during a transition and must not take clicks or focus.
content-visibility: hidden (CSS)No (its box stays)NoNoSkips its contents but keeps their rendering stateA huge panel that should come back faster than a full layout.
Off-screen with opacity: 0NoYesYesFullNever. 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.

State machine for one panel. A lazy panel starts unvisited, with no DOM. Selecting it moves it to visible, mounted and shown. Leaving it moves it to hidden, still mounted with effects paused, and returning moves it back to visible. In unmount mode, leaving moves it to unmounted, where its state is lost, and returning remounts it to visible. Eager panels start in hidden.unvisitedno DOMvisiblemounted, shownhiddenmounted, pausedunmountedstate lostselectleavereturnleave (unmount mode)return:remounteager starts herelazy startsunvisited
Lazy and eager panels move between visible and hidden, so their state survives. Only unmount mode goes through the unmounted state, where form input and scroll position are lost.

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?

A query parameter: ?tab=reviews

The page reads tab from the search params and passes it as value.

On change, it writes the new value with the router or history.replaceState.

Pros

  • The server sees it, so the first HTML already shows the Reviews panel, with no flash of Overview.
  • Plays well with other params like ?tab=reviews&stars=4.
  • Clearly view state of one page, not a new page.

Cons

  • Caches and CDNs may treat each value as a different URL; normalize or ignore the param in the cache key if the HTML is the same.
  • Needs a canonical link so search engines don't index one page four times.

The hash: #reviews

Read location.hash and listen for hashchange.

Pros

  • Never reaches the server, so no cache or SEO side effects.
  • Simple for static sites.

Cons

  • The server never sees it, so server rendering shows the default tab first and swaps after hydration: a visible flash and a layout shift.
  • Collides with in-page anchors, which also use the hash: a link to #shipping-faq leaves no room for the tab value.

A route segment: /settings/billing

Each tab is its own route; the router renders the matching panel.

Pros

  • Each section is a real page: its own title, analytics page view, code split, and data loader.
  • The back button walks between sections, which users expect for big sections.

Cons

  • Now each "tab" is a page, which is really navigation. Chapter 9 shows why that should use links, not the tabs pattern.
  • Heavier: a route change per click.

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.

tsx
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>
  );
}
Controlled by the URL. Unknown values fall back to Overview, and the next change writes a clean URL.

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.
State machine for one tab's data. Idle moves to loading when the tab is selected or prefetched. Loading moves to success on 200, or to error on failure. Error moves back to loading on Retry. Success moves to refreshing when the user returns and the data is stale, which keeps showing cached data, and refreshing moves back to success on 200.idlenothing yetloadingskeletonsuccessshow dataerrormessage, Retryrefreshingshow cachedselect200 OKfailRetryrevisit, stale200one machine pertab's cache key
Each tab has its own copy of this machine, keyed by what it shows, so a late reply for Reviews can only change the Reviews machine. Refreshing never shows a spinner: the cached data stays on screen.

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().

Sequence for opening the Specs tab, with four columns: User, Tabs, Query cache, and Specs API. The user hovers Specs and the Tabs report intent, so the cache prefetches with GET /specs. The user clicks Specs and the panel shows a skeleton from the pending entry. The API answers 503. The cache marks the entry as an error and the panel shows a message with a Retry button, while the tab stays selected. The user presses Retry, the cache sends GET /specs again, and the API answers 200 with the specs, which render.UserTabsQuery cacheSpecs APIhover Specsprefetch specsGET /specsclick Specs503error, RetryRetryGET /specs200 specs
The failure stays inside the Specs panel: the tab stays selected and the other panels keep their data. The hover started the request about 200 ms before the click, and the retry reuses the same cache key.

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.

ts
// 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);
Re-measure whenever a tab changes size, not only when the selection changes.

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-color can vanish: draw it with a border, or give it the Highlight system 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.
How the indicator is placed. A tab list with position relative holds Overview, Reviews, Specs, which is now selected, and Q&A. The old bar under Reviews slides to Specs. Specs starts 284 units from the left edge of the list, its offsetLeft, and is 90 wide, its offsetWidth. The bar gets transform translateX 284 pixels and scaleX 90. A ResizeObserver re-measures when fonts, badges, or the window change, and reduced motion makes the bar jump instead of slide.TabList, position: relativeOverviewReviewsSpecsQ&Aslides with transformoffsetLeft = 284offsetWidth = 90translateX(284px) scaleX(90)ResizeObserver: fonts,badges, window sizereduced motion:jump, no slide
Two numbers from the selected tab become one 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?

Horizontal scroll

overflow-x: auto on the tab list, scroll-snap-type: x proximity, and scroll-snap-align: start on tabs.

When a tab becomes selected or focused, scroll it into view with scrollIntoView({ block: "nearest", inline: "nearest" }).

A fade at the cut edge, and optionally arrow buttons for mouse users.

Pros

  • Same component, same keyboard model, same ARIA on every screen.
  • Familiar from app stores and news sites on phones.

Cons

  • Tabs past the edge are easy to miss; the fade is the only clue.
  • Desktop mouse users without a trackpad can't scroll sideways easily, hence the arrow buttons.

A "More" menu

Measure which tabs fit, show those, and move the rest into a menu button at the end.

Picking an item from the menu selects that tab.

Pros

  • Everything stays on one line with no hidden scroll.
  • Good for toolbars and app headers with 8 to 12 items.

Cons

  • Complex: measurement, a menu with its own ARIA pattern, and a selected tab that may sit inside the menu.
  • Screen readers hear a tab list with some tabs missing and a menu button inside it, which is harder to understand.

Switch pattern on small screens

Below a width, render a select (for navigation-like switching) or an accordion (for long content).

Use a container query on the component's own width, not the viewport.

Pros

  • A native select is the best picker on phones.
  • An accordion lets users see several sections in one scroll.

Cons

  • Two layouts, two ARIA patterns, and two sets of tests.
  • Kept state must survive the switch between layouts, so panel state has to live above both.

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.

Three ways to fit five tabs on a narrow phone, as three windows. Scroll and snap: the selected Reviews tab and Specs are scrolled into view, with cut-off tabs at faded edges on both sides. More menu: Overview is visible and a More button opens a list with Specs and Q&A. Accordion: Overview, Reviews, and Specs are stacked headers, with Reviews expanded to show its content.scrollReviewsSpecsfaded edge, snapMore menuOverviewMoreSpecsQ&AaccordionOverview +Reviews -Specs +the defaultdense toolbarslong forms
Scroll keeps one pattern on every screen, so it's the default. The More menu suits dense toolbars. The accordion suits long forms on settings pages, where users work through one section at a time.

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.

PartResponsibilityWhy 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.
TabListRenders 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.
TabRenders 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.
TabPanelRenders 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.
IndicatorMeasures 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.
Architecture. Inside the Tabs component zone: the Tabs root holds the value, ids, and visited set, and shares context with the TabList, which handles keys and the indicator, and with the TabPanel, which decides mounting and hiding. The TabList holds four Tab buttons. In the Page zone: the URL adapter reads tab from the URL and passes value to the root, and the root sends changes back. The TabPanel hosts the Reviews list, which reads from the query cache with useQuery. Tab buttons report intent to the query cache for prefetching. The query cache fetches from the Reviews API on the server.Tabs componentPageTabs (root)value, ids, visitedTabListkeys, indicatorTabPanelmount, hiddenTab x 4URL adapterreads ?tab=Reviews listpanel contentQuery cacheReviews APIvaluechangecontexthostsonTabIntentfetch
The component never touches the router or the network. It takes value, reports changes and intent, and the page decides what to store and what to load.
Data flow for the product page. The browser sends GET with tab=reviews to the server through the CDN, which returns HTML with Reviews already selected. The browser URL gives value to Tabs, and Tabs sends replace back to the URL when the user switches. Tabs mounts the Reviews panel, which asks the query cache with useQuery. The cache sends GET to the Reviews API, which returns JSON with 50 reviews, and the cache gives the data to the panel.Server, CDNrenders ?tabBrowser URL?tab=reviewsTabsvalue=reviewsReviews API50 itemsQuery cachereviews, 123Reviewspanel, lazyGETHTMLvaluereplacemountuseQueryGETJSONdatafirst paint: server picks tablater: one cache key per tab
The first paint is decided on the server from ?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.

ts
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)}`;
What the root shares with every part through context.

The public interface

PartProp or eventType and defaultNotes
Tabsvalue / defaultValuestringPass value for controlled mode, defaultValue for uncontrolled. Not both.
TabsonValueChange(value, { reason })Fires on every requested change. reason helps analytics and lets the page use push for clicks only, if it wants.
Tabsactivation"automatic" (default) or "manual"Manual for panels that load slowly.
TabsonTabIntent(value) => voidCalled on hover and focus; the page prefetches.
TabListorientation"horizontal" (default) or "vertical"Sets aria-orientation and the arrow keys.
TabListaria-label or aria-labelledbystring, requiredA tab list needs a name. Warn in development if missing.
Tabvalue, disabledstring, booleanDisabled tabs stay focusable with aria-disabled.
TabPanelvalue, mountstring, "lazy" (default), "eager", or "unmount"Per-panel mounting from chapter 5.
TabPanelsearchableboolean, falseUses 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.

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.

MinutesWhat to doWhat to say out loud
0 to 4Ask 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 9Sketch 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 16Draw 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 22Mounting 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 27Small screens, the indicator, RTL."Scroll with snap and scroll-into-view; transform for the indicator; arrow keys flip in RTL."
27 to 30Testing, 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?"

Think first

"The Profile tab has unsaved edits. What happens when the user clicks Security?"

Think first

"Switching to the Analytics tab takes 400 ms and INP is poor. What do you do?"

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, tab with aria-selected and aria-controls, tabpanel with aria-labelledby, and tabindex="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 useId so 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 with ResizeObserver, 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next