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

Two to four working days, tracked.

Thirty days, in the packaging it came in.

Two years against anything we got wrong.
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.

Small, medium, large.

Six of them, none of them beige.

Hides what we have run out of.
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.

Everyone in the workspace.

On the Team plan.

There is no undo.
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.
A kite, and the string for it.
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.

Two to four working days.

Thirty days.
open: shipping
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.

Left with a neighbour is fine.
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 rather than repeated here.
PropTypeDefaultWhat it does
childrenReact.ReactNode—The sections: one Accordion.Item each, holding an Accordion.Trigger and an Accordion.Panel.
valuestring[]—Controlled: the values of every panel standing open. Leave it out and the widget owns them.
defaultValuestring[]—Which panels start open. Left out, all of them are closed.
onValueChangeChangeHandler<string[], DisclosureReason>—Fires with the panels now open, in the order they were opened.
multiplebooleanfalseWhether several panels may stand open at once. Default false, so opening one closes the last.
loopbooleantrueWhether Down and Up wrap around at the ends. Default true.
levelAccordionLevel3Which 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.
PropTypeDefaultWhat it does
valuerequiredstring—What this section stands for, and what the widget reports when it is open.
childrenReact.ReactNode—The header and the panel.
disabledbooleanfalseNot 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.
PropTypeDefaultWhat it does
childrenReact.ReactNode—The header's label.
levelAccordionLevel—This header's heading level, when it differs from the one the accordion sets.
arrowbooleantrueWhether 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.
PropTypeDefaultWhat it does
childrenReact.ReactNode—The section's content.

Accordion keyboard

KeyResult
TabEvery 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, SpaceOpens the header's panel, or closes it.
Down / UpThe 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 / EndThe first and last header.

Accordion accessibility

  • 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.arrowvariants: open
accordion.trackvariants: 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 rather than repeated here.
PropTypeDefaultWhat it does
childrenReact.ReactNode—The content the trigger reveals.
triggerrequired(trigger: CollapsibleTrigger) => React.ReactNode—The control that opens it, handed the props that wire it to the panel. It has to be a button.
openboolean—Controlled open state. Leave it out and the widget owns it.
defaultOpenbooleanfalseWhether it starts open. Default false.
onOpenChangeChangeHandler<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.trackvariants: closed
collapsible.clip
collapsible.panel