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 withhidden="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.
| Situation | Best pattern | Why |
|---|---|---|
| FAQ with 25 questions, users want 1 or 2 | Accordion | Scan the titles, open one, done. The page stays short on a phone. |
| Product page: Overview, Reviews, Specs | Tabs | Alternative views of the same thing; comparing them side by side isn't needed. |
| Product page on a 360 pixel phone: Description, Shipping, Returns | Accordion | Tabs don't fit; the sections are independent and short. |
| A 3,000 word setup guide | Long page with anchors | Users need most of it in order, and want Ctrl+F and print to work. |
| Filter groups in a shop sidebar | Accordion, several open | Users open the 2 or 3 filters they care about and leave them open. |
Questions to ask the interviewer
| Question | Answer we assume | What 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) andmultiple. 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#refundsopens 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.
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.
- 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.
- 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: autocan't be transitioned in most browsers today. - 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.
- 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.
A div is not focusable, so keyboard users can't reach it with Tab. It doesn't react to Enter or Space. Screen readers announce it as plain text, not as a button, and say nothing about whether it is expanded.
Adding tabindex, key handlers and role="button" fixes some of it by hand. A real button element gives all of it for free, which is the first rule of ARIA in practice: use the native element when one exists. The semantic-html lesson goes deeper on this.
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?
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.
<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>
The details that matter in the APG pattern
- The heading level is a prop. A FAQ under an
h2page section needsh3items; the same component in a sidebar under anh3needsh4. Hardcodingh3breaks the page outline. ExposeheadingLevelwith 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-expandedlives on the button, not the panel. Screen readers announce it when the button gets focus: "Returns, button, expanded".aria-controlsis 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 thearia-expandedattribute 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 thedisabledattribute: 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.
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.
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?
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.
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">...
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.
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.
| Key | Required? | What it does |
|---|---|---|
Tab / Shift+Tab | Yes (native) | Moves to the next or previous focusable element: headers and anything inside open panels. |
Enter / Space | Yes (native button) | Toggles the focused header's panel. |
Down Arrow / Up Arrow | No: leave to the browser | Scrolls the page, as usual. Older APG versions let them move focus between headers; that guidance was removed in 2026. |
Home / End | No: leave to the browser | Scroll 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?
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.
.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; }
}
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.
closing plays, and only gets hidden when transitionend fires. Clicks during a transition just reverse it.- closed:
hidden(orhidden="until-found") is set;data-state="closed". - opening: remove
hidden, wait one frame so the 0fr row is painted, then setdata-state="open"so the transition runs. Ontransitionend, 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). Ontransitionendforgrid-template-rows, sethidden. 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.
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.
Decision How does the right panel end up open on load?
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.
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.
: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);
}
Writing the hash when a user opens an item
- Opt in. Only the help page wants the URL to follow the accordion. A
syncHashprop (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 settinglocation.hashalso scrolls the page. - Listen for
hashchange. An answer that says "see Refunds" links to#refundson 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?
Question 2 shrank by 900 pixels above it, so question 5's header moved up by 900 pixels, probably off the top of the screen. The user clicked and lost their place.
Fix it in the component: before the change, note the clicked header's position with getBoundingClientRect(). When an item above it is closing, close that one without animation and, after layout, scroll by the difference so the clicked header stays exactly where the pointer is. Browsers' scroll anchoring (overflow-anchor) handles some of these cases on its own, but not reliably for a click inside the moving content, so do it explicitly.
Check your understanding · Exercise 6
Which statements about deep links into the accordion are true? Select all that apply.
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.
| Part | Responsibility | Why 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. |
AccordionItem | Knows 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. |
AccordionTrigger | Renders 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. |
AccordionPanel | Hides with until-found, runs the four-state animation, listens for beforematch, mounts lazy content on first open. | Everything about how content appears and disappears. |
useControllableState | Value from props when controlled, from internal state when not; calls onValueChange either way. | Shared with tabs, select and dialog. |
useHashSync | Reads 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. |
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.
history.replaceState.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
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
defaultValuecome out witharia-expanded="true"and nohidden; closed items come out hidden. The first paint is already the final layout. - Make closed panels findable before hydration. React renders
hiddenas a boolean (hidden=""), so the server HTML has plainhidden, 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 sethidden="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
localStoragemeans 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
detailsinstead. 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:
h3items inside containh4items. - Each root is its own scope. The inner
Accordioncreates 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
beforematchon eachuntil-foundancestor, 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.
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");
});
| Layer | Tool | What it covers |
|---|---|---|
| Unit | Vitest or Jest, Testing Library, user-event | Single and multiple rules, collapsible, controlled and uncontrolled, onValueChange payloads, ids and aria-controls, disabled items, heading levels, beforematch handling. |
| Automated accessibility | axe-core in unit and end-to-end tests | Missing 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. |
| Browser | Playwright in Chromium, Firefox and WebKit | Deep 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. |
| Visual | Screenshot tests | Closed, open, focused, disabled, forced colors (Windows High Contrast) where the chevron and focus ring must use currentColor. |
| Manual | Screen readers | NVDA 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 4 | Scope: 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 9 | Markup: 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 15 | API: compound parts, type, collapsible, value and defaultValue, onValueChange. | The props as a TypeScript union. |
| 15 to 23 | Pick 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 28 | Testing and accessibility checks; nested accordions if asked. | The test layers table, said out loud. |
| 28 to 30 | Summarize 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?"
Share the state, not the markup. One value (the open section) lives in the page or the URL; a media query or container query decides whether to render Tabs or a single-mode Accordion with the same items. Each keeps its own correct ARIA, which a single element can't do, since a tab and an accordion header are different roles.
Render both on the server and switch with CSS, or pick on the server from a client hint, so there's no layout shift when JavaScript decides the width.
Think first
"Add an Expand all button."
Only in multiple mode. It sets value to every item's value in one update, so it's one render. Label it by what it will do: "Expand all" or "Collapse all", toggling with the state, and keep it a normal button outside the headings.
Panels with lazy content would all fetch at once; either fetch them or show their skeletons, and don't let 30 requests fire on a slow phone without thinking about it.
Think first
"The FAQ has 200 questions. Anything change?"
200 headers and closed panels are a few thousand DOM nodes, which is fine; closed until-found panels skip rendering their contents. What changes is usability: add a search box that filters questions, group them under category headings, and drop role="region" for sure.
If panels contain heavy widgets, mount them on first open. Virtualizing would break find-in-page and the heading outline, so it's the last resort, not the first.
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
buttonwitharia-expandedandaria-controls. Addrole="region"to panels only when about 6 or fewer can be open. Nativedetailswithnameis 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 modelsingle,multipleandcollapsibleexplicitly, witharia-disabledon a header that can't close. - Animate height with
grid-template-rowsfrom0frto1fr, addinterpolate-size: allow-keywordsas an enhancement, keep 150 to 250 ms, and handle reduced motion, where notransitionendwill fire. - Hide closed panels with
hidden="until-found"and open the item in state onbeforematch, so find-in-page and fragment links reveal text in current Chrome, Edge, Firefox and Safari while older ones fall back to plainhidden. - Deep links use the hash, a target inside the panel,
scroll-margin-topfor sticky headers,replaceStatewhen 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.
References
- WAI-ARIA Authoring Practices: Accordion pattern Roles, states, optional keys, and the advice about region landmarks.
- MDN: the details element The
openandnameattributes, the toggle event, and browser support. - MDN: the hidden global attribute The
until-foundstate and thebeforematchevent. - Chrome for Developers: Making collapsed content accessible with hidden=until-found
- Chrome for Developers: Animate to height auto
interpolate-sizeandcalc-size()explained. - MDN: ::details-content
- MDN: scroll-margin-top
- Nielsen Norman Group: Accordions on desktop When accordions help and hurt, including printing.
- web.dev: Cumulative Layout Shift What counts as a shift, and the 500 ms input exclusion.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.