cf.completefrontend
My progress
SYSTEM DESIGN · #2

Design an accordion

A design-system accordion: a stack of headings that each show or hide a panel. Toggling is easy; the real work is knowing when not to hide content, the right semantics (a button in a heading, or native details and summary), single versus multiple open with a controlled API, animating height to auto, keeping hidden text findable with find-in-page, opening the panel named in the URL, lazy panel content, and server rendering without layout shift.

The question

Design an accordion component for our design system. Marketing uses it for the FAQ on the pricing page, the shop uses it for filter groups and for the Shipping and Returns sections on product pages, and support links straight to single answers from emails.

Walk me through when you'd use it and when you wouldn't, the markup, the API for one open versus many open, how panels open smoothly, what happens to the text inside closed panels, and how a link opens the right panel.

Real-life examples: The FAQ on a pricing page such as Stripe's or Notion's, The filter groups (Brand, Price, Size) in an online shop's sidebar, The Description, Shipping and Returns sections on a phone-sized product page, A settings page with collapsible advanced options, The sections of a GitHub Actions run log

Requirements: what an accordion is for, and when not to use one

An accordion is a vertical stack of headers. Each header is a button that shows or hides the content under it, called its panel. Closed, the page is a short list of titles. Open, one or more panels push the content below them down the page.

It is often the first component in a design system interview because it looks trivial: a list, a boolean per item, a click handler. The interviewer is checking whether you see what that boolean costs. Content behind a click is content some people never read, cannot search, and cannot print. A senior answer starts with that question before it gets to the code.

When an accordion is the wrong tool

An accordion saves vertical space by hiding text. That is a good trade when people come for one or two items out of many, and a bad one when they need most of it. Nielsen Norman Group's guidance says the same: accordions help when users need only a few pieces of a long page, especially on small screens. When users need most or all of the content, show it, because every expand adds interaction cost and hides text from view.

  • People need most of the content. A checkout's terms, a short policy page, or a recipe's ingredients. Hiding them adds a click per section and hides what users must read. Use a normal page with headings.
  • People search with Ctrl+F or Cmd+F. Classic accordions hide panels with display: none, and the browser's find-in-page skips text that isn't rendered. A support page where nothing is findable is a real failure. We fix this later with hidden="until-found", but it is a reason to think twice.
  • People print or save as PDF. Closed panels don't print. A policy page printed for a lawyer comes out as a list of questions with no answers, unless print CSS opens them.
  • The sections are parallel views of one thing. Overview, Reviews and Specs of one product are alternatives you switch between: that is tabs. Tabs show one panel and keep the rest of the layout still. An accordion lets several panels be open at once and moves everything below.
  • The page is long but linear. A long article with a table of contents and anchor links keeps everything readable, printable and findable, and still lets people jump. That beats an accordion for documentation.
The same content question gives different patterns. Ask what share of the content a typical visitor needs.
SituationBest patternWhy
FAQ with 25 questions, users want 1 or 2AccordionScan the titles, open one, done. The page stays short on a phone.
Product page: Overview, Reviews, SpecsTabsAlternative views of the same thing; comparing them side by side isn't needed.
Product page on a 360 pixel phone: Description, Shipping, ReturnsAccordionTabs don't fit; the sections are independent and short.
A 3,000 word setup guideLong page with anchorsUsers need most of it in order, and want Ctrl+F and print to work.
Filter groups in a shop sidebarAccordion, several openUsers open the 2 or 3 filters they care about and leave them open.
A decision tree for picking a pattern. First question: do most visitors need most of the text? Yes leads to a long page with headings and anchor links. No leads to a second question: are the sections alternative views of one thing? Yes leads to tabs, which show one panel and keep the layout still. No leads to an accordion, where users scan titles and open one or two.Need most of the text?yesLong pageheadings + anchorsnoViews of one thing?yesTabsone panel at a timenoAccordionopen 1 or 2 of manyFAQ, filter groups, phone product page: accordion
Ask what share of the content a typical visitor needs before you reach for an accordion. On a narrow phone, an accordion is also the usual fallback when tabs don't fit.

Questions to ask the interviewer

QuestionAnswer we assumeWhat it changes
How many items?FAQ: up to 30. Filters: 4 to 8. Product page: 3 to 5.With more than about 6 panels, role="region" on every panel floods the landmarks list, so we drop it.
One open at a time, or many?FAQ: many. Product page: one. Filters: many, with some open by default.Both modes in one component, and a choice of whether the last open panel can close.
Who owns the open state?Usually the component. Support pages want the open item in the URL hash.A controlled and an uncontrolled API, and deep links.
What's inside a panel?Text and links in the FAQ; a filter form; on product pages, a shipping estimate loaded from an API.Lazy loading on first open, and keeping state such as a half-filled form when closed.
Animation?Yes, a short height animation, off for users who ask for reduced motion.The classic problem: CSS can't transition height to auto without help.
Server rendered?Yes, with hydration. The FAQ must be indexable.Default open panels are open in the server HTML; closed text is still in the HTML.
Browsers and devices?Last two versions of Chrome, Edge, Firefox and Safari; 60 percent of FAQ traffic is on phones.Feature detection for newer CSS, and touch targets at least 44 pixels tall on mobile.

Functional requirements

  • Click or press Enter or Space on a header to toggle its panel.
  • Two modes: single (opening one closes the others) and multiple. In single mode, an option decides whether the open panel can be closed, leaving all closed.
  • Controlled and uncontrolled: the parent can own the open items, or just give defaults.
  • Disabled items that can't be toggled but still read their title.
  • A link such as /help#refunds opens and scrolls to that item.
  • Text in closed panels is found by the browser's find-in-page, where the browser supports it.

Non-functional requirements

  • Accessible: works with keyboard alone and announces expanded or collapsed with screen readers (NVDA, JAWS, VoiceOver, TalkBack).
  • Fast: a toggle responds within one frame and keeps Interaction to Next Paint (INP) well under the 200 ms good threshold. Opening animates in 150 to 250 ms.
  • Stable: no layout shift on load; Cumulative Layout Shift (CLS) contribution of 0 from the accordion.
  • Small: under about 3 KB of JavaScript gzipped for the component itself, and working HTML before hydration.

Out of scope: drag to reorder items, a virtualized accordion with thousands of items (that is a tree or a list problem), and sections that save their open state to the server per user.

Check your understanding · Exercise 1

Which of these pages are a good fit for an accordion? Select all that apply.

What makes a simple accordion hard

Here is the parts list. Every accordion item has a header (a heading element that holds the toggle button), a trigger (the button itself, with the title and a chevron icon), and a panel (the content). The root wraps all items and owns the rule for which ones are open.

Wireframe of the help page accordion at shop.example/help#refunds. Four items stacked: Shipping, closed; Refunds, open, with three lines of answer text in its panel below; Exchanges, closed; Warranty, closed. Labels on the right name each header as an h3 with a button, the aria-expanded state on the open button, and the open panel. A note says the chevron is aria-hidden.shop.example/help#refunds▸ Shipping▾ Refunds▸ Exchanges▸ Warrantyh3 + buttonaria-expandedopen panelchevron:aria-hidden
Each header is a heading with one button inside; the chevron only mirrors aria-expanded. Closed panels are still in the HTML, hidden with hidden="until-found", so search and find-in-page can reach them.

Four properties of this component drive every decision in the rest of the guide.

  1. It hides content. Whatever we use to hide a panel decides whether screen readers, find-in-page, search engines and print can see it. Hiding is the whole job, so the hiding method is the most important decision, not a detail.
  2. It changes the page height. Opening a 400 pixel panel moves everything below it by 400 pixels. That is expected after a click, but the same move during page load is layout shift, and the same move with no animation is jarring. Animating it hits a CSS limit: height: auto can't be transitioned in most browsers today.
  3. Its state lives in more than one place. The open items can belong to the component, to the parent page, or to the URL hash. A link from a support email must open the right answer. The API must allow all three without three components.
  4. It is used everywhere, by teams you don't talk to. A FAQ, filters, a settings form, nested sections in docs. A design system component gets one chance at its API and markup, because every change after that breaks someone.

Notice what isn't on the list: performance at scale. An accordion has 3 to 30 items. Rendering cost is tiny unless panels contain heavy content, and that becomes a loading question (fetch on first open), not a rendering one.

Think first

A teammate builds the header as a div with an onClick and a chevron icon. It works with a mouse. List what is broken before reading on.

Semantics: a button in a heading, or details and summary

The markup decides what a screen reader user hears and what a keyboard user can do. Get it right and most accessibility comes for free. Get it wrong and no amount of ARIA added later fixes it cleanly. The aria-patterns lesson covers the APG patterns in general; here we apply the accordion one.

Decision What should one accordion item be made of?

A div with a click handler

The header is a div with onClick that flips a boolean.

The panel is shown or hidden with a CSS class.

Pros

  • Fastest to write.
  • Full control over styling.

Cons

  • Not focusable, no Enter or Space, no role, no expanded state.
  • Rebuilding all of that by hand is more code than using a button, and easy to get partly wrong.

APG pattern: a button inside a heading

Each header is a heading (h2 to h6, matching the page outline) that contains one button.

The button has aria-expanded and aria-controls pointing at the panel id.

The panel is a div, hidden when closed, optionally with role="region" and aria-labelledby pointing back at the button.

Pros

  • The heading puts every item in the page outline, so screen reader users jump between items with the H key.
  • The button gives focus, Enter, Space and a clear announcement such as "Shipping, button, collapsed".
  • Full control over state: single mode, always one open, controlled API, animations.

Cons

  • Needs JavaScript to toggle, so it does nothing before hydration.
  • We write the open and close logic, ids and hiding ourselves.

Native details and summary

Each item is a details element; its first child summary is the header; the rest is the panel.

The open attribute shows the content. Giving several details the same name makes them exclusive: opening one closes the others.

Pros

  • Works with zero JavaScript, including before hydration.
  • Keyboard, focus and the expanded state are built in.
  • Browsers open a closed details when find-in-page matches text inside it (Chrome since 97, Firefox since 139, Safari since 26.2; MDN notes that early Firefox and current Safari versions don't scroll to the match correctly).

Cons

  • No "always one open" rule: the user can always close the open item.
  • A heading inside summary is exposed inconsistently by browser and screen reader pairs, so heading navigation may skip it.
  • Styling the marker and animating the content need newer CSS (::details-content), and the toggle happens before your code can react, so a controlled API fights the browser.

What we pick: The design system's Accordion uses the APG pattern: a button in a heading. It's the only option that supports every mode in our requirements (single, multiple, always one open, controlled, URL driven) and keeps items in the heading outline. We still recommend native details for simple, static content (a 5 question FAQ in a CMS page with no JavaScript budget) and document when to pick which. Both are correct; the requirements decide.

html
<h3 class="acc-header">
  <button type="button" id="acc-shipping-trigger"
    aria-expanded="false" aria-controls="acc-shipping-panel">
    Shipping
    <svg aria-hidden="true" class="acc-chevron">...</svg>
  </button>
</h3>
<div id="acc-shipping-panel" hidden="until-found">
  <p>Orders ship in 2 business days.</p>
</div>
One closed APG accordion item as the browser sees it. The panel id and button id come from the item's value, so they match on server and client.

The details that matter in the APG pattern

  • The heading level is a prop. A FAQ under an h2 page section needs h3 items; the same component in a sidebar under an h3 needs h4. Hardcoding h3 breaks the page outline. Expose headingLevel with a default of 3.
  • Only the button is inside the heading. No extra links or menus in the header row: a heading with two interactive controls confuses both the outline and the click target. If an item needs actions (such as Delete), put them next to the heading, not inside the button.
  • aria-expanded lives on the button, not the panel. Screen readers announce it when the button gets focus: "Returns, button, expanded".
  • aria-controls is a hint. Of the major screen readers, mainly JAWS does anything with it. Keep it, since it is cheap and correct, but don't count on it.
  • role="region" only for few panels. A region with a name becomes a landmark. The APG advises against it when more than about 6 panels can be open at once, because 25 FAQ landmarks bury the page's real landmarks. Our component adds it automatically when there are 6 items or fewer, and a prop overrides that.
  • The chevron is decoration. Mark it aria-hidden="true"; the expanded state already carries the meaning. Rotate it with CSS from the aria-expanded attribute so the visual and the announced state can't drift apart.
  • A panel that can't close says so. In single mode where one item must stay open, the open item's button gets aria-disabled="true". Not the disabled attribute: that removes it from the Tab order and users lose their place.

When native details is the better choice

The name attribute on details arrived in Chrome and Edge 120, Safari 17.2 and Firefox 130, so exclusive accordions now work with plain HTML in all current browsers. The ::details-content pseudo-element, which lets you style and animate the content part, followed in Chrome 131, Safari 18.4 and Firefox 143. On older versions both degrade safely: items just stop closing each other, or open without animation.

So for static content with no special rules, details is less code and works before JavaScript loads. Its limits are the ones listed above: no forced open item, the heading question, and state owned by the browser. A common middle ground in design systems is to render details in a CMS rich text renderer and the APG component in the app.

Markup
Try
The Refunds item built as an h3 with a button. Action: Tab to it. The item is closed. A screen reader says: Refunds, heading 3, button, collapsedh3button: Refundsaria-expanded="false"div#refunds-panelhidden="until-found"text hidden, still findableScreen reader says:Refunds, heading 3,button, collapsedworks
Tab lands on the button. Wording and order vary by screen reader, but the name, role, state and heading level are all there.

Check your understanding · Exercise 2

A FAQ has 24 questions under an h2 titled Frequently asked questions, and many can be open at once. Which markup choices are right? Select all that apply.

Open state, keyboard, and the component API

Two rules describe how items open. The type says how many can be open: single (at most one) or multiple (any number). In single mode, collapsible says whether the open item can be closed, leaving none open. A product page often wants single and not collapsible, so one section is always showing; a FAQ wants multiple.

Type
Click a heading
An accordion in single mode, collapsible. Open items: returns.▸ shipping▾ returnsReturn within 30 days.▸ warranty▸ sizingvalue ="returns"type: singlecollapsible: true1 open
Start: returns is open.

What the open state looks like

The naive state is a boolean per item, kept inside each item. It breaks at the first real requirement: single mode needs an item to close its siblings, which it can't see. A deep link needs the root to open an item by name, and "Expand all" needs one place to set everything.

So the state lives in the root, as the list of open item values: a string in single mode (empty string for none), an array of strings in multiple mode.

Why item values and not indexes? Indexes shift when a CMS editor adds a question at the top, and then a saved or linked open state points at the wrong item. A stable string such as "refunds" survives reordering, reads well in a URL hash, and gives the element ids for free.

Decision Who owns the open state?

Uncontrolled only

The root keeps the state; the parent passes defaultValue once.

Pros

  • Simplest to use: drop it in a page and it works.

Cons

  • The page can't open an item from a hash, a button, or a search result.
  • No way to save or sync the state.

Controlled only

The parent passes value and must update it in onValueChange.

Pros

  • Full power: URL sync, analytics, "expand all".

Cons

  • Every simple FAQ now needs a useState in the page, and forgetting onValueChange makes a dead accordion.

Both, like a native input

If value is given, the parent owns it; otherwise the root keeps its own state seeded from defaultValue.

onValueChange fires in both cases.

Pros

  • Simple by default, powerful when needed. This is how input works in React, so it's familiar.

Cons

  • A bit more code in the root, and switching between modes after mount must warn in development.

What we pick: Support both, with a small useControllableState hook that every design system component shares. The component-api-design lesson covers the pattern; the accordion is a clean example of it.

ts
type Common = {
  headingLevel?: 2 | 3 | 4 | 5 | 6;
  disabled?: boolean;
  children: React.ReactNode;
};
type SingleProps = Common & {
  type: "single";
  value?: string;            // controlled ("" = none open)
  defaultValue?: string;     // uncontrolled
  onValueChange?: (value: string) => void;
  collapsible?: boolean;     // default true
};
type MultipleProps = Common & {
  type: "multiple";
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
};
export type AccordionProps = SingleProps | MultipleProps;
// Usage: <Accordion type="single" defaultValue="shipping">
//          <AccordionItem value="shipping" title="Shipping">...
A discriminated union: TypeScript rejects value="a" in multiple mode, and collapsible only exists in single mode.

The API is compound: Accordion, AccordionItem, AccordionTrigger and AccordionPanel, sharing state through React context. A config array (items={[{ title, body }]}) looks simpler, but the first team that needs a badge in one header or a form in one panel is stuck. Compound parts keep each piece open to plain JSX.

Sequence for opening Returns in controlled single mode, with five columns: User, Trigger, Root, Page, and Panel. The user clicks or presses Enter on the Returns header. The Trigger calls toggle with returns on the Root. The Root computes the next value and calls onValueChange on the Page. The Page stores it and passes value returns back down. The Root tells the Returns panel it is open and the Shipping panel it is closed. The panel animates open over 200 ms for the user. In uncontrolled mode the Root stores the value itself and the Page step is skipped.UserTriggerRootPagePanelclick, Entertoggle(v)onValueChangevalueisOpen: trueopens, 200 msuncontrolled: Root keeps thevalue, no Page round trip
The root is the only place that applies the single or multiple rule. In uncontrolled mode it keeps the value itself; in controlled mode it only proposes the next value through onValueChange, and the page decides.

Keyboard: Tab, Enter and Space, and no arrow keys

Unlike tabs, where the tab list is one Tab stop and arrows move inside it (roving focus, covered in the tabs guide), every accordion header is its own Tab stop. That's simpler and correct: Tab and Shift+Tab move between headers and into open panels, Enter and Space toggle. A FAQ with 24 items has 24 Tab stops, which is fine because users usually want the content in between.

The keys the APG accordion pattern lists today. Everything else keeps its normal browser meaning.
KeyRequired?What it does
Tab / Shift+TabYes (native)Moves to the next or previous focusable element: headers and anything inside open panels.
Enter / SpaceYes (native button)Toggles the focused header's panel.
Down Arrow / Up ArrowNo: leave to the browserScrolls the page, as usual. Older APG versions let them move focus between headers; that guidance was removed in 2026.
Home / EndNo: leave to the browserScroll to the top or bottom of the page, as usual.

Until April 2026 the APG listed Up, Down, Home and End as optional keys that move focus between headers. It removed them after testers reported the problem: with focus on a header, Down Arrow jumped to the next header instead of scrolling, so users couldn't read the panel they had just opened.

Screen reader users already jump between headers with the H key, because each one is a real heading. So the design system doesn't add arrow keys, and keeps aria-disabled headers (the locked open one) in the Tab order. The keyboard-navigation lesson has the general rules.

Check your understanding · Exercise 3

A product page uses type="single", not collapsible, with Description open by default. Which statements are true? Select all that apply.

Animating height: the classic problem

Design asks for panels that slide open in about 200 ms instead of snapping. The naive CSS is height: 0 when closed, height: auto when open, and transition: height 200ms. It does nothing: the panel snaps. CSS transitions interpolate between two numbers, and auto isn't a number until layout computes it, so for most of CSS's history browsers simply refused to animate it.

There are four families of fixes. They differ in browser support, how much JavaScript they need, and what happens when a user clicks again halfway through an animation, which real users do all the time.

Decision How should a panel animate from 0 to its natural height?

max-height hack

Transition max-height from 0 to a large guess such as 1000px.

Pros

  • Pure CSS, works everywhere.

Cons

  • The timing is wrong. With linear timing over 250 ms and a 200 pixel panel, the visible part of opening is done in 50 ms, and closing waits 200 ms before anything moves.
  • Panels taller than the guess get cut off.

Measure in JavaScript

Read panel.scrollHeight, then either transition height to that many pixels and set height: auto on transitionend, or call the Web Animations API with the measured number: panel.animate([{ height: "0px" }, { height: "240px" }], { duration: 200 }) and clean up when animation.finished resolves.

Pros

  • Exact timing, works everywhere.
  • With the Web Animations API, full control from JavaScript: cancel(), reverse(), and a promise for the end.

Cons

  • Extra layout reads and writes, and care with interruptions.
  • If content changes height mid-animation (an image loads), the measured number is stale.
  • More code than CSS.

Grid rows from 0fr to 1fr

The panel is display: grid with grid-template-rows: 0fr closed and 1fr open; one inner child has min-height: 0 and overflow: hidden.

Transition grid-template-rows.

Pros

  • Pure CSS animation with exact timing; animatable in Chrome 107+, Firefox 66+ and Safari 16+.
  • Clicking mid-way reverses smoothly from the current height, since it's a normal CSS transition.

Cons

  • Needs a wrapper element, and the panel must stay rendered while it closes (it can't be hidden until the transition ends).

interpolate-size and calc-size()

interpolate-size: allow-keywords on the root lets height animate to and from auto; calc-size(auto, size) does it for one property.

Pros

  • The naive CSS just works. Combined with ::details-content, it animates native details too.

Cons

  • Chrome and Edge 129+ only as of late 2026; Firefox and Safari snap. Fine as an enhancement, not as the plan.

What we pick: Use grid rows (0fr to 1fr) as the default: it's CSS, it reverses well, and it works in every browser we support. Add interpolate-size: allow-keywords in the base CSS as a free enhancement for any team writing plain height transitions. Keep measuring with the Web Animations API for the rare case that needs JavaScript control, such as a sequence of animations. Never the max-height hack.

Technique
Panel heightBrowser
Visible panel height over 250 ms for maxheight with a 200 pixel panel in chrome. open: visible motion ends at 50 ms. close: nothing moves for 200 ms.solid: openingdashed: closingfull00 ms125 ms250 msopen: visible motion ends at 50 ms; close: nothing moves for 200 ms
Works everywhere, but the timing depends on how wrong the 1000px guess is. Linear timing over 250 ms, for easy numbers.
css
.acc-panel {
  grid-template-rows: 0fr;
  transition: grid-template-rows 200ms ease-out;
}
.acc-panel:not([hidden]) { display: grid; }
.acc-panel[data-state="open"] { grid-template-rows: 1fr; }
.acc-panel > .acc-inner {
  min-height: 0;      /* lets the row shrink below the content */
  overflow: hidden;
}
.acc-chevron { transition: rotate 200ms ease-out; }
[aria-expanded="true"] .acc-chevron { rotate: 180deg; }

@media (prefers-reduced-motion: reduce) {
  .acc-panel, .acc-chevron { transition: none; }
}
The grid rows technique. The data-state attribute is set by the component; reduced motion turns the transition off. display: grid applies only while the panel isn't hidden, because an author display value beats the browser's default style for hidden.

Four states, not two

Animating forces a small state machine. Open and closed aren't enough: while closing, the panel must still be rendered so the transition can play, and only at the end may it become hidden. Model it as four states.

State machine for one animated panel with four states. Closed goes to opening on a click, which removes hidden. Opening goes to open on transitionend. Open goes to closing on a click. Closing goes to closed on transitionend, which sets hidden. A click during opening goes to closing, and a click during closing goes back to opening, so the motion reverses. Find-in-page, deep links and reduced motion skip the middle states.closedhidden setopening0fr to 1fropenrow is 1frclosing1fr to 0frclicktransitionendclickend: set hiddenclick againclick againfind, hash, reducedmotion: skip the middle
Two states are not enough: the panel must stay rendered while closing plays, and only gets hidden when transitionend fires. Clicks during a transition just reverse it.
  • closed: hidden (or hidden="until-found") is set; data-state="closed".
  • opening: remove hidden, wait one frame so the 0fr row is painted, then set data-state="open" so the transition runs. On transitionend, go to open.
  • open: nothing to do. Content can grow freely because the row is 1fr.
  • closing: set data-state="closed" (row goes to 0fr). On transitionend for grid-template-rows, set hidden. If the user clicks again first, go back to opening: the transition reverses from where it is.

Cost. Animating height or grid rows runs layout on every frame, and moves everything below the panel. On a normal page, layout for one accordion takes a millisecond or two, well inside the 16.7 ms frame at 60 Hz.

The cheaper-looking transform: scaleY() avoids layout but squashes the text while it moves and leaves a gap below, so it looks wrong. Keep the duration at 150 to 250 ms; long animations make a page feel slow and push users toward the reduced motion setting. The visual-accessibility lesson explains who turns it on and why.

Check your understanding · Exercise 4

A panel uses the grid rows technique with a 200 ms transition. Which statements are true? Select all that apply.

What happens to closed panels: hiding, find-in-page, lazy content

A closed panel can be removed from the DOM, kept but hidden, or kept but hidden in a way the browser can still search. The tabs guide compares every hiding technique in a table; this chapter applies the same rules to the accordion, where the stakes are higher. A FAQ's whole value is its text, and that text is closed most of the time.

Decision How should a closed panel be hidden?

Unmount it

Render the panel only while its item is open, and remove it from the tree on close.

Pros

  • Nothing to render for closed items; effects in closed panels don't run.

Cons

  • The answer text isn't in the server HTML, so search engines and find-in-page never see it.
  • Form state inside the panel is lost on close.
  • The open animation has nothing to measure on the first frame.

Keep it, with the hidden attribute

Always render the panel; set hidden when closed.

Pros

  • Text is in the HTML for search engines and no-JavaScript readers.
  • State survives close and reopen; out of the accessibility tree and Tab order when closed.

Cons

  • Find-in-page skips it: users press Ctrl+F, type "refund", and get zero matches on a page about refunds.

Keep it, with hidden="until-found"

Same as above, but the value until-found. When find-in-page or a link to a text fragment matches inside, the browser fires beforematch on the panel, removes hidden, and scrolls to the match.

Pros

  • Everything the plain hidden attribute gives, plus findable text. Older browsers treat it as plain hidden, so it fails safely.

Cons

  • Our state must follow the browser: if we don't handle beforematch, the button still says collapsed and the next render hides the panel again.
  • The panel's own box stays in layout (it uses content-visibility: hidden), so padding goes on the inner wrapper.
  • React (19.x) treats hidden as a plain boolean and writes hidden="" for any value, so in React the until-found value must be set through the DOM.

What we pick: hidden="until-found" by default, falling back to plain hidden automatically in older browsers. Unmount only for panels whose content is loaded on demand and not worth indexing, and even then only until first open.

Support for hidden="until-found" and beforematch: Chromium since version 102 (2022), Firefox since 139 (2025), Safari since 26.2 (late 2025). MDN notes that Firefox before 148 and Safari 26.2 don't scroll to the match correctly, so test it there.

tsx
function useFindable(ref: React.RefObject<HTMLDivElement>, value: string, phase: PanelPhase) {
  const { open } = useAccordionContext();
  useLayoutEffect(() => {
    const el = ref.current;
    // React only writes hidden="" (a boolean), so set the until-found value here
    if (el && phase === "closed") el.setAttribute("hidden", "until-found");
  }, [ref, phase]);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const onMatch = () => open(value, { animate: false });
    el.addEventListener("beforematch", onMatch);
    return () => el.removeEventListener("beforematch", onMatch);
  }, [ref, value, open]);
}
Keep the root's state in step with the browser. The panel renders hidden={phase === "closed"}; React writes that as hidden="", and the layout effect upgrades it before paint. On beforematch the browser has already decided to reveal the panel; we open the item so the button, siblings and URL agree.

Note animate: false. The browser scrolls to the match right after beforematch; if the panel then grows over 200 ms, the match slides away from where the browser scrolled. Opens triggered by find-in-page or a deep link skip the animation. Only clicks animate.

In single mode, a find match in a closed item closes the open one. That's correct: the root's rule still holds, and the user sees the panel with their match.

Lazy content: fetch on first open, keep it after

Most panels hold static text, so it should be in the HTML. Some don't. The product page's Shipping panel shows a delivery estimate for the user's postcode from GET /api/shipping-estimate, about 120 ms at the median and 800 ms on a slow phone connection. Fetching it for every product view wastes a request on every visitor who never opens the panel, which on a typical product page is most of them.

  • Fetch on first open. The panel mounts its content the first time it opens and remembers that it has opened (hasOpened in a ref). After that it stays mounted and hidden, so closing and reopening doesn't refetch or lose the spinner's place.
  • Start early. Begin the request on pointerenter or focus of the header. The gap between pointing at a header and clicking it is often a few hundred milliseconds, enough to hide much or all of a 120 ms request.
  • Show a skeleton with a fixed height. The panel animates open to the skeleton's height, then grows a little when data arrives, instead of animating to 0 and jumping.
  • Handle failure inside the panel. Show a short message and a Retry button in the panel, and keep the header working. A failed estimate must not break the accordion.
  • Cache by key. Use the app's server cache (React Query, SWR, or the router's loader cache) keyed by product and postcode, so the second product page with the same postcode is instant.
Sequence for the Shipping panel's lazy delivery estimate, failure path, with four columns: User, Panel, Query cache, and API. The user points at the Shipping header and the Panel asks the cache to prefetch. The cache sends GET shipping-estimate to the API. The user clicks to open, and the Panel shows a skeleton with a fixed height. The API replies with a 500 error, the cache passes the error to the Panel, and the Panel shows a short message with a Retry button while the header keeps working. On success, the reply fills the skeleton instead.UserPanelQuery cacheAPIhover headerprefetchGET /estimateclick: openskeleton500 errorerrormessage + Retry
The request starts on pointerenter, before the click, so on success the data often arrives before the panel finishes opening. On failure, only the panel shows an error; the accordion keeps working.

Check your understanding · Exercise 5

A help page has 30 questions in an accordion. A user presses Ctrl+F and types "invoice", which appears only in a closed answer. Which setups show them the match? Select all that apply.

Deep links: opening the panel named in the URL

Support emails a customer https://shop.example/help#refunds. The page must load with the Refunds answer open and on screen, below the site's 64 pixel sticky header, not hidden under it. The url-as-state lesson covers the URL in general; here are the accordion's details.

The naive version reads location.hash in a useEffect and opens the item. Two things go wrong. The effect runs after hydration, which on a mid-range phone can be 2 to 4 seconds after the HTML is painted, so the user sees a closed FAQ, starts reading, and then everything below Refunds jumps down. That is layout shift the user didn't cause, and it counts against CLS.

The second problem is scrolling. The browser tried to scroll to the #refunds target during load, while it was still hidden, so after the late open the answer can sit half off the screen until the effect scrolls again.

Open after hydration

An effect reads location.hash, opens the item without animation, then scrolls it into view.

Pros

  • Simple; works in every browser.

Cons

  • Late: a closed panel is visible until JavaScript runs, then content jumps.

Let the browser reveal it with until-found

Put the fragment's target id inside the panel (on its inner wrapper). When the URL's fragment points inside a panel that has hidden="until-found", the browser fires beforematch, reveals it, and scrolls there while the page loads, before the app's JavaScript bundle runs. In React this needs closed panels to carry until-found in the HTML, covered under server rendering below.

On hydration, the item sees the hash and opens in state too, without animation.

Pros

  • No JavaScript needed for the first reveal, so no late jump in supporting browsers.

Cons

  • Only where until-found is supported; still needs option 1 as the fallback.
  • The scroll target is the panel, so the header above it needs room: scroll-margin-top.

Server-rendered open item from a query parameter

Links use /help?open=refunds#refunds. The server reads open and renders that item open.

Pros

  • Open in the very first HTML, in every browser, even with JavaScript off.

Cons

  • The server never sees the hash, so plain #refunds links need another option anyway.
  • Each value is a different page for the CDN cache: 30 questions means up to 31 cached copies.

What we pick: Combine 2 and 1: hash links, a target id inside each panel so supporting browsers reveal it during load, and an on-mount fallback that opens the item without animation. Use the query parameter only for pages that must work without JavaScript. The hash also keeps every link to the FAQ cacheable as one page.

Sequence for loading shop.example/help#refunds, with four columns: User, Browser, Panel, and Root. The user opens the link. The browser parses the HTML and finds the refunds target inside a panel with hidden until-found. It fires beforematch on the panel and reveals it, then scrolls to it, leaving room for the sticky header through scroll-margin-top. Later the Root hydrates, reads the hash, and opens refunds in state without animation, so the button says expanded. A note says that in browsers without until-found the panel stays closed until hydration, then opens late.UserBrowserPanelRootopen #refundstarget insidebeforematchrevealed, scrolledhydrate: openbutton: expandedno until-found support: Root opensit after hydration, which is late
The browser does the first reveal during load, before the app bundle runs. Hydration only has to agree with it. Without until-found support, the on-mount fallback opens the item later, which is the path that can shift content.

Scrolling with a sticky header

Browsers scroll a fragment target to the very top of the viewport, right under a sticky site header that covers it. CSS fixes it without JavaScript: scroll-margin-top on the target tells every scroll into view, native or scrollIntoView(), to leave that much room. Since our target is inside the panel, the margin must also leave room for the item's own header.

css
:root {
  --site-header-h: 64px;
  --acc-header-h: 56px;
}
.acc-inner[id] {
  /* sticky site header + this item's header + breathing room */
  scroll-margin-top: calc(var(--site-header-h) + var(--acc-header-h) + 8px);
}
One rule covers fragment navigation, links that reveal an until-found panel, and our own scrollIntoView calls. Find-in-page scrolls to the matched text, not the element, so it doesn't use the margin.

Writing the hash when a user opens an item

  • Opt in. Only the help page wants the URL to follow the accordion. A syncHash prop (or the page using the controlled API) turns it on; filter groups never touch the URL.
  • Replace, don't push. Use history.replaceState(null, "", "#refunds"). Pushing would make the Back button step through every question the user opened, and setting location.hash also scrolls the page.
  • Listen for hashchange. An answer that says "see Refunds" links to #refunds on the same page. The root listens and opens that item, without animation, then scrolls to it.
  • Multiple mode. A hash names one place. In multiple mode, write the most recently opened item, and clear it when that item closes.

Think first

Single mode. Question 2 is open and 900 pixels tall. The user scrolls down and clicks question 5's header. Question 2 closes, question 5 opens. Where is question 5's header now, and what should the component do?

Check your understanding · Exercise 6

Architecture, data model, server rendering and nesting

Time to put the decisions together. The component is four parts that share one context, plus three small hooks that other design system components already use or can reuse.

PartResponsibilityWhy the boundary is here
Accordion (root)Owns the open values (controlled or not), applies the single or multiple rule, syncs the hash when asked.Every rule that involves more than one item needs one owner.
AccordionItemKnows its value and disabled, builds the ids that link trigger and panel, tells children whether it is open.Ids and per-item state in one place, so trigger and panel can't disagree.
AccordionTriggerRenders the heading at the right level and the button with aria-expanded, aria-controls and aria-disabled.All the header semantics in one piece teams can't break by accident.
AccordionPanelHides with until-found, runs the four-state animation, listens for beforematch, mounts lazy content on first open.Everything about how content appears and disappears.
useControllableStateValue from props when controlled, from internal state when not; calls onValueChange either way.Shared with tabs, select and dialog.
useHashSyncReads the hash on mount and on hashchange, writes it with replaceState.Only pages that opt in pay for it.
Server cache (React Query or similar)Fetches and caches lazy panel data by key.The accordion doesn't know about APIs; panels do.
Component architecture. In the browser: the FAQ page passes props to the Accordion root, which holds the open values. A useHashSync hook feeds the hash to the root. The root shares context with the Trigger, an h3 with a button, and passes isOpen to the Panel, which handles until-found and the animation. On first open, the Panel asks the Query cache for lazy data, and the cache sends GET estimate to the API on the server. A note says useId keeps ids the same on server and client.BrowserServerFAQ pageowns value, or notpropsuseHashSyncread, replacehashAccordionopen valuescontextisOpenTriggerh3 + buttonPaneluntil-found, animfirst openQuery cacheby productGET /estimateAPIuseId:same idson server
Only the root knows the rules; triggers and panels just read context. Network access stays out of the accordion: a lazy panel talks to the app's query cache, which talks to the API.

Data moves in a loop. The server renders HTML with default items open. The user clicks, the trigger asks the root to toggle, and the root computes the next value and either stores it or hands it to the parent through onValueChange.

The new value flows down as props, and each panel runs its animation. Lazy panels call the server cache on first open; the hash hook writes the URL. Nothing else talks to the network.

Data flow for the help page. The server renders HTML with default items open and passes it to the CDN, which caches one copy per page and sends it to the browser. The browser HTML has closed panels with hidden until-found. Hydration creates the Accordion root, which reads the hash and holds the open values. The root passes isOpen to the panels. A panel's first open asks the query cache, which fetches from the API. When the user opens an item, the root writes the URL hash with replaceState.Server renderdefault openHTMLCDNone copyHTMLBrowser HTMLuntil-found panelshydrateAccordion rootvalue: [refunds]isOpenPanelsshow, hidefirst openQuery cachelazy dataGETAPIJSON estimatereplaceStateURL hash#refunds
The hash never reaches the server or the CDN, so every link to the help page shares one cached HTML copy. The only network traffic after load is lazy panel data, and the only write is history.replaceState.
ts
type PanelPhase = "closed" | "opening" | "open" | "closing";

type AccordionState = {
  type: "single" | "multiple";
  value: string[];             // single mode stores 0 or 1 entries internally
  collapsible: boolean;
};

type ItemContext = {
  value: string;               // stable: "refunds", never an index
  isOpen: boolean;             // derived: state.value.includes(value)
  disabled: boolean;
  triggerId: string;           // `${rootId}-${value}-trigger`, rootId from useId()
  panelId: string;
  headingLevel: 2 | 3 | 4 | 5 | 6;
};

type PanelState = { phase: PanelPhase; hasOpened: boolean }; // per panel, local
The client data model. Only value is real state; everything else is derived or per panel.

useId() gives an id prefix that matches between server and client, so aria-controls and aria-labelledby survive hydration without warnings. Combining it with the item's value keeps ids readable in the DOM and unique when two accordions on the page both have a "shipping" item.

Server rendering without layout shift

  • Render the real starting state on the server. Items in defaultValue come out with aria-expanded="true" and no hidden; closed items come out hidden. The first paint is already the final layout.
  • Make closed panels findable before hydration. React renders hidden as a boolean (hidden=""), so the server HTML has plain hidden, and find-in-page and deep links can't reveal anything until the layout effect upgrades it. A few lines of inline script right after the accordion's HTML can set hidden="until-found" on its closed panels during parsing. Hydration accepts that, because React only checks that a boolean attribute is present.
  • Don't measure on mount. The grid technique needs no height numbers, so hydration does no layout reads. A scrollHeight-based panel that renders closed on the server and measures on the client is a common source of a one-frame flash.
  • Never decide the open state on the client from client-only data. "Remember which sections were open" from localStorage means the server renders one layout and the client switches to another: a jump. Store it in a cookie so the server can render it, or don't remember it.
  • Before hydration, headers don't toggle. On a slow phone that window can be a few seconds. For most pages that's fine because the content is readable and findable. For a no-JavaScript requirement, render native details instead. The ssr lesson covers the hydration gap in general.

Nested accordions

Docs and settings pages nest accordions: a Billing section with Invoices and Payment methods inside. Four things to get right:

  • Heading levels step down. The inner root reads the outer item's level from context and defaults to one more: h3 items inside contain h4 items.
  • Each root is its own scope. The inner Accordion creates a new context, so inner items never register with the outer root, and the outer root's rules (single mode, Expand all) never reach inner items.
  • Heights keep working. With grid rows, the outer row is 1fr, so it grows when an inner panel opens. With fixed pixel heights from measuring, the outer panel would clip the inner one: another reason for the grid technique.
  • Find-in-page reveals the whole chain. A match in a closed inner panel inside a closed outer panel reveals both: the browser fires beforematch on each until-found ancestor, and each item opens itself in state.

Check your understanding · Exercise 7

Which of these would cause a layout shift on a server-rendered FAQ? Select all that apply.

Testing and the last quality checks

A design system component is tested by its contract: what users and assistive technology see, not internal state. Write tests against roles and attributes, the same things a screen reader reads. Then test the browser-only behaviour (animation, find-in-page, scrolling) in a real browser, because jsdom has no layout and no find-in-page.

tsx
test("single, not collapsible: opening one closes the other", async () => {
  const user = userEvent.setup();
  render(<Faq type="single" collapsible={false} defaultValue="shipping" />);
  const shipping = screen.getByRole("button", { name: "Shipping" });
  const returns = screen.getByRole("button", { name: "Returns" });
  expect(shipping).toHaveAttribute("aria-disabled", "true");

  returns.focus();
  await user.keyboard("{Enter}");
  expect(returns).toHaveAttribute("aria-expanded", "true");
  expect(shipping).toHaveAttribute("aria-expanded", "false");
});

test("beforematch opens the item in state", () => {
  render(<Faq type="multiple" />);
  const panel = document.getElementById(screen.getByRole("button", { name: "Refunds" })
    .getAttribute("aria-controls")!)!;
  act(() => { panel.dispatchEvent(new Event("beforematch")); });
  expect(screen.getByRole("button", { name: "Refunds" })).toHaveAttribute("aria-expanded", "true");
});
Testing Library with user-event: queries by role and name, asserts on the ARIA a screen reader would announce.
LayerToolWhat it covers
UnitVitest or Jest, Testing Library, user-eventSingle and multiple rules, collapsible, controlled and uncontrolled, onValueChange payloads, ids and aria-controls, disabled items, heading levels, beforematch handling.
Automated accessibilityaxe-core in unit and end-to-end testsMissing names, broken aria-controls ids, contrast of the header text. Automated rules find only part of real issues, so it isn't the whole check.
BrowserPlaywright in Chromium, Firefox and WebKitDeep link opens and scrolls below the sticky header; no layout-shift entries on load; reduced motion with page.emulateMedia({ reducedMotion: "reduce" }) hides panels; clicking twice during the animation.
VisualScreenshot testsClosed, open, focused, disabled, forced colors (Windows High Contrast) where the chevron and focus ring must use currentColor.
ManualScreen readersNVDA with Firefox or Chrome, JAWS with Chrome, VoiceOver on macOS and iOS Safari, TalkBack on Android Chrome: each announces name, button, expanded or collapsed, and the heading level.

Two checks people forget. First, zoom to 400 percent at a 1280 pixel window (WCAG reflow): long header titles must wrap, not clip, and the chevron must stay next to the text. Second, a page with two accordions that share item values: the ids must still be unique, which the useId prefix guarantees and a unit test can assert by counting duplicate ids in the document.

Check your understanding · Exercise 8

Which of these can a jsdom unit test check reliably? Select all that apply.

Running it in the interview

This is a 30 minute question, and the trap is spending 20 of them on a toggle. Show early that you know where the real decisions are, then go deep on two or three of them.

MinutesWhat to doWhat to draw or say
0 to 4Scope: what goes in panels, single or multiple, URL, server rendering. Say when you wouldn't use an accordion."If users need most of this text, I'd argue for a page with anchors."
4 to 9Markup: heading, button, aria-expanded, aria-controls, region only for few panels. Mention native details and when it's enough.The wireframe with the parts named, and the HTML for one item.
9 to 15API: compound parts, type, collapsible, value and defaultValue, onValueChange.The props as a TypeScript union.
15 to 23Pick two deep dives: height animation and hidden content, or deep links and server rendering.The four-state panel machine; the until-found flow.
23 to 28Testing and accessibility checks; nested accordions if asked.The test layers table, said out loud.
28 to 30Summarize the trade-offs you chose."Button in heading, until-found, grid animation, hash with replaceState."

Say the reasons out loud. "I'm using hidden=\"until-found\" because people search FAQ pages with Ctrl+F" earns more credit than the attribute alone. Interviewers grade the trade-off, not the trivia.

Follow-ups to expect

Think first

"Our product page uses tabs on desktop and an accordion on mobile. How would you build that?"

Think first

"Add an Expand all button."

Think first

"The FAQ has 200 questions. Anything change?"

Summary

  • An accordion is for content where most visitors need one or two sections. When they need most of it, a page with headings and anchors is better: readable, findable with Ctrl+F, and printable.
  • Each header is a heading that contains a button with aria-expanded and aria-controls. Add role="region" to panels only when about 6 or fewer can be open. Native details with name is a good, JavaScript-free choice for simple static content.
  • Keep the open values in the root as stable strings, support both controlled (value, onValueChange) and uncontrolled (defaultValue) use, and model single, multiple and collapsible explicitly, with aria-disabled on a header that can't close.
  • Animate height with grid-template-rows from 0fr to 1fr, add interpolate-size: allow-keywords as an enhancement, keep 150 to 250 ms, and handle reduced motion, where no transitionend will fire.
  • Hide closed panels with hidden="until-found" and open the item in state on beforematch, so find-in-page and fragment links reveal text in current Chrome, Edge, Firefox and Safari while older ones fall back to plain hidden.
  • Deep links use the hash, a target inside the panel, scroll-margin-top for sticky headers, replaceState when writing, and no animation for opens the user didn't click.
  • Render the real starting state on the server, never measure on mount, and never decide open items on the client from client-only data: that is how accordions cause layout shift.

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