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:
shippingJSX
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.
| 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 |
|---|---|---|---|
valuerequired | 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.
- Each header is a
<button aria-expanded>inside a heading —levelsays which, and it has to fit the outline of the page around it. aria-controlson 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. Passprops={{ 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 whataria-expandedalready 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.accordionaccordion.itemaccordion.headingaccordion.triggeraccordion.arrowvariants: openaccordion.trackvariants: closedaccordion.clipaccordion.panelCollapsible 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.
| Prop | Type | Default | What it does |
|---|---|---|---|
children | React.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. |
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-expandedandaria-controls; the content has no role of its own, because a region wants a name and a trigger is not a heading. Use anAccordionwhere 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.collapsiblecollapsible.trackvariants: closedcollapsible.clipcollapsible.panel