# Accordion

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/accordion/_

APG's accordion, and the disclosure under it — where the height animation is a shared class rather than a pixel somebody measured.

**Import**

```jsx
import Accordion, { Collapsible } from '@box-kite/react/components/accordion';
```

**Usage**

```jsx
<Accordion defaultValue={['shipping']}>
  <Accordion.Item value="shipping">
    <Accordion.Trigger>Shipping</Accordion.Trigger>
    <Accordion.Panel>Two to four working days, tracked.</Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item value="returns">
    <Accordion.Trigger>Returns</Accordion.Trigger>
    <Accordion.Panel>Thirty days, in the packaging it came in.</Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item value="warranty">
    <Accordion.Trigger>Warranty</Accordion.Trigger>
    <Accordion.Panel>Two years against anything we got wrong.</Accordion.Panel>
  </Accordion.Item>
</Accordion>
```

## The height animation is a class

Every other library measures: an effect reads the panel's height, writes it into a custom property, and a `ResizeObserver` keeps it up to date. Nothing here does. The panel sits in a grid of one row whose track runs `1fr` to `0fr` — so the height that is animated is the one the browser was going to compute anyway.

A hundred items share one rule

There is no number to write down, so there is nothing per instance: two classes do the whole animation, and they are the same two classes on every accordion on the page. A measured height is a rule per panel that is never freed.

Content that grows while it is open grows with it

Nothing was pinned, so an image that loads or a list that fetches simply makes the panel taller. A measured height has to notice and re-measure.

A closed panel is hidden, not merely clipped

`visibility` is what takes it out of the tab order and the accessibility tree — and unlike `display` it is animatable, so it flips to hidden only once the track has closed and back the instant it opens. That is also why the entrance needs no `@starting-style`: a server-rendered open panel does not animate itself open on load.

It rides `--transitionTime` like everything else, which `prefers-reduced-motion` zeroes with no opt-in — turn the setting on and the panels snap. The panel is always in the DOM, so what it holds survives being shut; gate children too expensive to render closed yourself.

## Several at once

One panel at a time is the default, so opening one closes the last. `multiple` lets them all stand open — and either way, closing the open one is allowed, so an accordion can stand with everything shut and needs no second prop to say so.

```jsx
<Accordion multiple defaultValue={['size', 'colour']}>
  <Accordion.Item value="size">
    <Accordion.Trigger>Size</Accordion.Trigger>
    <Accordion.Panel>Small, medium, large.</Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item value="colour">
    <Accordion.Trigger>Colour</Accordion.Trigger>
    <Accordion.Panel>Six of them, none of them beige.</Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item value="stock">
    <Accordion.Trigger>In stock only</Accordion.Trigger>
    <Accordion.Panel>Hides what we have run out of.</Accordion.Panel>
  </Accordion.Item>
</Accordion>
```

## The keyboard, and the heading

An accordion is not a composite widget: _every_ header is its own tab stop, and Down and Up are a shortcut between them rather than the only way in. That is the opposite of `Tabs`, where the whole list is one stop — and it is APG's rule for each.

Each header is a real `<button>` inside a heading, which is the pair the pattern asks for. The level is `level`, default `3`, and it has to fit the page around it: a heading level is the document's outline, and a screen reader navigates by it. A disabled section is the `disabled` attribute, so the browser has already taken it out of the tab sequence, and Down and Up step over it for the same reason.

```jsx
<Accordion level={2}>
  <Accordion.Item value="who">
    <Accordion.Trigger>Who can see this</Accordion.Trigger>
    <Accordion.Panel>Everyone in the workspace.</Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item value="audit" disabled>
    <Accordion.Trigger>Audit log</Accordion.Trigger>
    <Accordion.Panel>On the Team plan.</Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item value="delete">
    <Accordion.Trigger>Delete this project</Accordion.Trigger>
    <Accordion.Panel>There is no undo.</Accordion.Panel>
  </Accordion.Item>
</Accordion>
```

The panel is a `role="region"` named by its header. Past half a dozen of them the landmarks are noise, which is APG's own caveat — `props={{ role: undefined }}` on the panel drops it.

## One on its own

`Collapsible` is the same mechanism with no heading, no group and no arrow keys, because a lone button needs none of them. Note the shape: the trigger is the render prop and the children are the content, the way `Popover` reads.

```jsx
<Collapsible trigger={(trigger) => <Button {...trigger}>What is in the box?</Button>}>
  <Box pt={3}>A kite, and the string for it.</Box>
</Collapsible>
```

Its content carries no role of its own: a region wants a name, and a trigger is not a heading. Where the sections are document structure, that is what `Accordion` is for.

## Controlled

Pass `value` — the values of every panel standing open — and the consumer owns it. `onValueChange` reports the new set and why it changed.

```jsx
const [value, setValue] = useState(['shipping']);

<Accordion value={value} onValueChange={setValue}>
  …
</Accordion>
```

## A section keeps what is in it

A closed panel is hidden rather than unmounted, so a half-filled form in one survives being shut — type into this and close it. The headers are read off the DOM rather than out of a registry, so a header a consumer wrapped, rendered from a list or put behind a condition navigates like any other, and an accordion inside a panel belongs to itself.

```jsx
<Accordion defaultValue={['address']}>
  <Accordion.Item value="address">
    <Accordion.Trigger>Delivery address</Accordion.Trigger>
    <Accordion.Panel>
      <Textbox props={{ 'aria-label': 'Postcode' }} />
    </Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item value="note">
    <Accordion.Trigger>Note for the driver</Accordion.Trigger>
    <Accordion.Panel>Left with a neighbour is fine.</Accordion.Panel>
  </Accordion.Item>
</Accordion>
```

## Styling

Every part is a Box, so every prop is available, and the defaults live in `Box.components('accordion')` — with `accordion.item`, `accordion.heading`, `accordion.trigger`, `accordion.arrow` and `accordion.panel` beneath it. The open state on a header is its own `aria-expanded`, so `ariaAttr={{ expanded: … }}` is what styles it — no variant, because the attribute the pattern already has to write is the selector.

```jsx
<Accordion.Trigger
  py={4}
  ariaAttr={{ expanded: { color: 'emerald-600' } }}
>
  Shipping
</Accordion.Trigger>
```

Two parts are not yours: `accordion.track`, the grid whose row animates, and `accordion.clip` inside it, the bare item that clips. Bare is the point — padding cannot be squeezed, so a grid item carrying any floors the `0fr` track at exactly that much, which is why the panel you pad sits inside the clip rather than being it. That padding is also the room a focus ring at the panel's edge needs, since the clip is permanent.

## Accordion props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on [/box](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The sections: one `Accordion.Item` each, holding an `Accordion.Trigger` and an `Accordion.Panel`. |
| `value` | `string[]` | — | Controlled: the values of every panel standing open. Leave it out and the widget owns them. |
| `defaultValue` | `string[]` | — | Which panels start open. Left out, all of them are closed. |
| `onValueChange` | `ChangeHandler<string[], DisclosureReason>` | — | Fires with the panels now open, in the order they were opened. |
| `multiple` | `boolean` | `false` | Whether several panels may stand open at once. Default `false`, so opening one closes the last. |
| `loop` | `boolean` | `true` | Whether Down and Up wrap around at the ends. Default `true`. |
| `level` | `AccordionLevel` | `3` | Which heading each header sits in. Default `3`. It has to fit the page around it — a heading level is the document's outline, and a screen reader navigates by it. |

### Accordion.Item

One section. It owns the pair of ids its header and its panel are wired together by.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `value`required | `string` | — | What this section stands for, and what the widget reports when it is open. |
| `children` | `React.ReactNode` | — | The header and the panel. |
| `disabled` | `boolean` | `false` | Not openable, and stepped over by Down and Up. It is the `disabled` attribute, so the browser has already taken the header out of the tab sequence — arrows that still stopped there would be reaching what Tab cannot. |

### Accordion.Trigger

The header: a real `<button>` in a heading, which is the pair APG asks for.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The header's label. |
| `level` | `AccordionLevel` | — | This header's heading level, when it differs from the one the accordion sets. |
| `arrow` | `boolean` | `true` | Whether to draw the chevron. Turn it off to supply an affordance of your own. Default `true`. |

### Accordion.Panel

The section's content, in the grid that gives it its height.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The section's content. |

## Accordion keyboard

| Key | Result |
| --- | --- |
| `Tab` | Every header is in the tab sequence, and so is anything inside an open panel. An accordion is not a composite widget, so nothing here is a roving tabindex. |
| `Enter, Space` | Opens the header's panel, or closes it. |
| `Down / Up` | The next and previous header, wrapping at the ends and stepping over disabled ones. They do nothing inside a panel, so a textarea in one keeps its own arrows. |
| `Home / End` | The first and last header. |

## Accordion accessibility

Implements [the WAI-ARIA APG pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/).

- Each header is a `<button aria-expanded>` inside a heading — `level` says which, and it has to fit the outline of the page around it.
- `aria-controls` on the header names its panel, which is always in the DOM, so the reference cannot dangle.
- The panel is a `role="region"` named by its header. Pass `props={{ role: undefined }}` on a long accordion: past half a dozen panels the landmarks are noise, which is APG's own caveat.
- The chevron is `aria-hidden`: it says what `aria-expanded` already says.

Swept with axe on every release, in this state: `Accordion`. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

## Accordion style tree

Every part the component draws is a node with a name, so a default can be restyled with `Box.components()` instead of a selector — and a variant is a name too.

`accordion`

`accordion.item`

`accordion.heading`

`accordion.trigger`

`accordion.arrow`variants: open

`accordion.track`variants: closed

`accordion.clip`

`accordion.panel`

## Collapsible props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on [/box](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The content the trigger reveals. |
| `trigger`required | `(trigger: CollapsibleTrigger) => React.ReactNode` | — | The control that opens it, handed the props that wire it to the panel. It has to be a button. |
| `open` | `boolean` | — | Controlled open state. Leave it out and the widget owns it. |
| `defaultOpen` | `boolean` | `false` | Whether it starts open. Default `false`. |
| `onOpenChange` | `ChangeHandler<boolean, DisclosureReason>` | — | Fires with the new state and why it changed. |

## Collapsible accessibility

- The trigger carries `aria-expanded` and `aria-controls`; the content has no role of its own, because a region wants a name and a trigger is not a heading. Use an `Accordion` where the sections are document structure.

Swept with axe on every release, in this state: `Collapsible`. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

## Collapsible style tree

Every part the component draws is a node with a name, so a default can be restyled with `Box.components()` instead of a selector — and a variant is a name too.

`collapsible`

`collapsible.track`variants: closed

`collapsible.clip`

`collapsible.panel`

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

