Accordion
A stack of Disclosures with headings, arrow-key movement between them, and an optional one-open-at-a-time rule — the APG accordion built from the system's disclosure.
Basic usage
Install the package:
pnpm add @design-schema/react @design-schema/tokens Import the stylesheet once for the app, then the component:
import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Accordion } from '@design-schema/react'; Examples
import { Accordion, type AccordionItem, Text } from '@design-schema/react';
const ITEMS: AccordionItem[] = [
{
id: 'cancel',
summary: 'What happens if I cancel?',
content: <Text>You keep access until the end of the billing period.</Text>,
},
{
id: 'plans',
summary: 'Can I change plans later?',
content: <Text>Yes. Changes take effect at the next billing date.</Text>,
},
{
id: 'refunds',
summary: 'Do you offer refunds?',
content: <Text>Within 14 days of a charge, in full.</Text>,
},
];
<Accordion items={ITEMS} />Props
7 rows| Prop | Type | Default | Description |
|---|---|---|---|
| items required | array { id: string; summary: string; content: ReactNode; disabled?: boolean }[] | — | The sections in order. `content` is the panel body; on Lit it is dropped from the item type and the body is a light-DOM child slotted by the item id (`<div slot="faq-1">`); light-DOM children that are neither a `<ds-disclosure>` nor slotted by an item id are not rendered, and only child additions and removals are observed (changing an existing child's `slot` attribute later is not). The optional field follows the package convention for optional properties (`disabled?: boolean | undefined`). `disabled` forwards to the Disclosure's own `disabled`: the section cannot be toggled, and its trigger stays focusable and a tab stop (`aria-disabled`, not `disabled`), so arrow keys and Home/End skip it on web and Lit. On React Native, which has no arrow navigation, a disabled item differs only in that its trigger does not respond to a press and carries `accessibilityState.disabled`. |
| headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level for every trigger, so sections appear in the page outline. |
| exclusive | boolean | false | Opening one section closes the others. Off by default: users usually want to compare, and forced-closing is a common frustration. Turning it on while several sections are open trims the open set to the first open id without firing any event. The development warning is about a declared set, not about a trim: it fires whenever `exclusive` is on and the resolved `value`/`defaultValue` holds more than one id — a standing controlled `value` is such a declaration and keeps warning (once per distinct id list) as long as it holds several ids — and never fires for the trim of sections a user opened while uncontrolled. "First" is array order: the order of `value`/`defaultValue`, and for uncontrolled state the order the sections were opened, not item order. Uncontrolled, the trim changes state for good, so turning `exclusive` off again does not reopen the trimmed sections; controlled, the trim only affects what is shown, so turning `exclusive` off shows the full `value` again, still without events. Toggling `exclusive` does not consume a pending just-emitted set (see Behavior). |
| value | string | string[] | — | Controlled open ids. A bare `string` is accepted as shorthand for a one-id array; an empty array or an empty string means nothing is open. Events always report an array, with zero or one entry when `exclusive`. |
| defaultValue | string | string[] | — | Initially open ids; the same shapes as `value`. Given both, `value` controls and `defaultValue` is ignored, with no warning. |
| divided | boolean | true | A hairline between items. |
| keepMounted | boolean | false | Passed to every Disclosure; required when panels contain form fields. Accordion-wide, like `headingLevel`: the `items` shape carries no per-item override for either, so an accordion cannot keep only some panels mounted. |
Events
2 rows| Event | Type | Description |
|---|---|---|
| onChange | onChange / change | Fired when the set of open sections changes, with the open ids — always an array, even under `exclusive`, where it carries zero or one entry. |
| onOpenChange | onOpenChange / open-change | Fired per section as it opens or closes, with `{ id, open, reason }` (`reason`: `trigger`, `keyboard`, `exclusive` when another section closed it, `controlled`). The per-item trigger for analytics, lazy loading of a panel's content, or scrolling the opened section into view; `onChange` remains the set-level event for state. |
Accessibility
Decisions this component leaves to you
Choose the heading level so it follows the heading above it on your page. The component will not guess the outline it lands in.
No implicit ARIA role — the component is styling and structure, and adds nothing to the accessibility tree.
Anatomy
What generation guarantees
Heading hierarchy — Headings this component renders take their level from the caller, so the page outline never skips a level.
Expanded state — Open and closed are exposed programmatically (`aria-expanded`), not only visually.
Arrow navigation — Arrow keys move between items; Home and End jump to the ends.
Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.
Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.
Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.
Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).
This component does not animate, so it has no reduced-motion behaviour to describe.