Accordion
Headings that open and close a panel of content each, so long content can fold away.
On this page
React, prerendered to static pages, served from S3 and CloudFront.
They're built for this site, but the source is on GitHub to read and learn from.
Unit tests with axe, and browser tests in Chromium and WebKit.
import { Accordion, AccordionItem } from "@/components/Accordion";
export function AccordionExample() {
return (
<Accordion headingLevel={2}>
<AccordionItem heading="What is this site built with?">
<p>React, prerendered to static pages, served from S3 and CloudFront.</p>
</AccordionItem>
<AccordionItem heading="Can I use these components?">
<p>They're built for this site, but the source is on GitHub to read and learn from.</p>
</AccordionItem>
<AccordionItem heading="How are they tested?">
<p>Unit tests with axe, and browser tests in Chromium and WebKit.</p>
</AccordionItem>
</Accordion>
);
}Examples
Independent items
A personal site that doubles as a showcase for its own component library.
React and TypeScript, prerendered to static HTML and served from AWS. Every look value comes from a design token.
They're built for this site, but the source is open: read it, borrow the patterns.
Exclusive
A personal site that doubles as a showcase for its own component library.
React and TypeScript, prerendered to static HTML and served from AWS. Every look value comes from a design token.
They're built for this site, but the source is open: read it, borrow the patterns.
Controlled
Opened and closed from outside, or by its own button.
The item is closed.
Rich heading
The badge's icon is decorative; its text is part of the button's name.
Announced as: “Release notes Live, heading level 4, button, collapsed”
Find in page
The missing word is dogs.
It's raining cats and ____. Search this page for the missing word: the item opens to show the match.
Regions
Orders ship within two working days.
Return anything within thirty days.
Each open panel is announced as a region named by its heading.
Heading look
A personal site that doubles as a showcase for its own component library.
React and TypeScript, prerendered to static HTML and served from AWS. Every look value comes from a design token.
They're built for this site, but the source is open: read it, borrow the patterns.
Customized with component tokens
A strong line and wider padding.
.customAccordion {
--component-accordion-border: var(--color-border-strong);
--component-accordion-trigger-padding-inline: var(--space-inset-md);
--component-accordion-panel-padding-inline: var(--space-inset-md);
}Props
Any native prop not listed here goes to the root element unchanged.
An item's children are its panel. heading must not hold links or buttons: the whole heading is one button. An AccordionItem works alone, with level 3 and the h4 look. With exclusive, items opened together on first render stay open until one opens. Use region only for six or fewer panels.
| Prop | Type | Default | Description |
|---|---|---|---|
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Every item's heading level. Choose it from the page outline. |
headingVariant | TypographyVariant | "h4" | Every item's heading look, the same at every level. |
exclusive | boolean | false | Opening an item closes the others. |
region | boolean | false | Makes each panel a region named by its heading. Only for six or fewer panels. |
| Prop | Type | Default | Description |
|---|---|---|---|
heading | ReactNode | — | Required. The heading's content, inside its button. No links or buttons. |
open | boolean | — | Controlled open state; pair with onOpenChange. |
defaultOpen | boolean | false | Uncontrolled initial state. |
onOpenChange | (open: boolean) => void | — | Called on every requested open or close, controlled or not, including an exclusive group closing it and find-in-page opening it. |
Component tokens
| Token | Copy | Description | Aliases | Light | Dark | Preview |
|---|---|---|---|---|---|---|
--component-accordion-border | Lines above each item and below the last (decorative: the button's text and chevron mark each item). | color.border.subtle | #e5dccbfrom color.sand.200 | #272f3ffrom color.slate.800 | ||
--component-accordion-border-width | Line thickness. | effect.border.width | 2pxfrom effect.line.2 | 2pxfrom effect.line.2 | ||
--component-accordion-trigger-text | Heading button text and chevron. | color.text.default | #17202bfrom color.gray.900 | #fffffffrom color.gray.0 | ||
--component-accordion-trigger-background-hover | Heading button background on hover (only where the device can hover), as Button's ghost hover. | color.surface.sunken | #f2ede2from color.sand.100 | #060a13from color.slate.1000 | ||
--component-accordion-trigger-padding-block | Block padding of the heading button. | space.stack.sm | 0.5remfrom space.2 | 0.5remfrom space.2 | ||
--component-accordion-trigger-padding-inline | Inline padding of the heading button. | space.inset.sm | 0.5remfrom space.2 | 0.5remfrom space.2 | ||
--component-accordion-trigger-gap | Space between the heading text and the chevron. | space.inline.md | 1remfrom space.4 | 1remfrom space.4 | ||
--component-accordion-focus-ring-color | Focus indicator, drawn inside the heading button so it stays clear of the lines. | color.focus.ring | #005f7bfrom color.cyan.700 | #54d8f4from color.cyan.300 | ||
--component-accordion-panel-padding-inline | Inline padding of the panel, aligning its content with the heading text. | space.inset.sm | 0.5remfrom space.2 | 0.5remfrom space.2 | ||
--component-accordion-panel-padding-block-end | Space between the panel's content and the next line. | space.stack.md | 1remfrom space.4 | 1remfrom space.4 | ||
--component-accordion-transition-duration | Duration of the panel's height and the chevron's turn (0ms under reduced motion). | motion.duration.base | 250msfrom motion.time.250Reduced motion: 0ms | 250msfrom motion.time.250 | No preview | |
--component-accordion-transition-easing | Easing of the panel's height and the chevron's turn. | motion.easing.standard | cubic-bezier(0.4, 0, 0.2, 1)from motion.curve.in-out | cubic-bezier(0.4, 0, 0.2, 1)from motion.curve.in-out | No preview |
Accessibility
- Each item is a heading holding one button, so the headings stay in the page outline: choose headingLevel to fit it.
- The button announces whether it's expanded (aria-expanded) and points to its panel (aria-controls).
- A closed panel is out of the Tab order and the accessibility tree, yet find-in-page and links to its content open it (hidden="until-found", in browsers that support it).
- No arrow keys move between headings, so arrows keep scrolling the page; Tab reaches every button.
- With region, each panel is a region named by its heading. More than about six regions clutter the landmarks list, so it's off by default.
- The height and chevron animate over component.accordion.transition-duration, 0ms under reduced motion, when the item's own button opens or closes it. A change made from outside, by a controlled parent, applies at once.
- In forced colors, the lines, chevron and focus ring use system colors, and the hover background is dropped.
| Key | Action |
|---|---|
| Tab / Shift+Tab | Moves between headings' buttons and into open panels. |
| Enter / Space | Opens or closes the item. |
| Arrow keys | Scroll the page, as usual; there's no movement between headings. |