30% offEnding soon
All questions

Accordion Compound Components

Premium

Accordion Compound Components

Build a headless, accessible accordion whose pieces share state through React context. Callers should be able to compose Accordion.Item, Accordion.Trigger, and Accordion.Panel freely while the root enforces one-open-at-a-time behavior and each item maintains correct accessibility links.

Signature

function Accordion(props: {
  defaultValue?: unknown;
  children?: React.ReactNode;
}): React.ReactNode;

Accordion.Item(props: {
  value: unknown;
  children?: React.ReactNode;
}): React.ReactNode;

Accordion.Trigger(
  props: React.ButtonHTMLAttributes<HTMLButtonElement>,
): React.ReactElement;

Accordion.Panel(
  props: React.HTMLAttributes<HTMLDivElement>,
): React.ReactElement;

Export { Accordion } from compoundComponent.js. Attach the three leaf components as static properties: Accordion.Item, Accordion.Trigger, and Accordion.Panel.

Examples

const accordion = React.createElement(
  Accordion,
  { defaultValue: 'billing' },
  React.createElement(
    Accordion.Item,
    { value: 'billing' },
    React.createElement(Accordion.Trigger, null, 'Billing'),
    React.createElement(Accordion.Panel, null, 'Update payment details'),
  ),
  React.createElement(
    Accordion.Item,
    { value: 'shipping' },
    React.createElement(Accordion.Trigger, null, 'Shipping'),
    React.createElement(Accordion.Panel, null, 'Change delivery address'),
  ),
);

// Billing begins open. Clicking Shipping closes Billing and opens Shipping.
// Clicking Shipping again closes it, leaving both panels hidden.

A caller can cancel the built-in transition:

React.createElement(
  Accordion.Trigger,
  {
    className: 'dangerous-action',
    onClick(event) {
      if (!window.confirm('Reveal this section?')) event.preventDefault();
    },
  },
  React.createElement('strong', null, 'Private details'),
);

Requirements

  • The root owns one value. Initialize it from defaultValue, which defaults to null. This is uncontrolled state: later defaultValue changes do not replace live state.
  • Values retain identity. Compare values without stringifying or otherwise coercing them. Opening one item stores that exact value; clicking it again stores null.
  • Items provide scope. Accordion.Item requires a value prop and supplies its value plus accessibility metadata to its descendants. It adds no DOM wrapper.
  • Triggers toggle. Render a button, default its type to "button", and support normal button props and arbitrary children. Call a supplied onClick first. Toggle only when that event has not been default-prevented.
  • Panels remain mounted. Always render a div with role="region"; use the hidden attribute when its item is closed. Support normal div props and arbitrary children.
  • Accessibility stays linked. A trigger's aria-controls must name its panel, and the panel's aria-labelledby must name its trigger. IDs must be unique and stable across state changes and parent rerenders. The trigger also exposes the current boolean through aria-expanded.
  • Invalid composition is explicit. Throw clear errors when an item is outside an accordion, when an item omits value, or when a trigger or panel is outside an item.

Out of scope

Do not add controlled state, roving keyboard focus, animation, persistence, multi-select behavior, or CSS. The browser's native button keyboard behavior is sufficient for this task.