Accordion
A list of rows that open. Replaces the legacy ExpansionPanel.
Overview
A list of rows that open. Replaces the legacy ExpansionPanel.
First example
<Accordion defaultValue="shipping"><Accordion.Item value="shipping"><Accordion.Trigger>Livraison<Accordion.Indicator /></Accordion.Trigger><Accordion.Content><Text>Sous trois jours ouvrés.</Text></Accordion.Content></Accordion.Item></Accordion>
Anatomy
<Accordion>
<Accordion.Item value="…">
<Accordion.Trigger>
…
<Accordion.Indicator />
</Accordion.Trigger>
<Accordion.Content>…</Accordion.Content>
</Accordion.Item>
</Accordion>
Accordion— the container. Holds what is open, resolves every slot's style, and draws the separators between its children.Accordion.Item— one row. It owns the height animation, because the height that changes is the row's.Accordion.Trigger— the part you press. Carriesexpandedfor a screen reader.Accordion.Indicator— the chevron, turning with the panel.Accordion.Content— the panel. Mounted when open, absent when not.
Usage
Basic
<Accordion defaultValue="shipping">
<Accordion.Item value="shipping">
<Accordion.Trigger>
Livraison
<Accordion.Indicator />
</Accordion.Trigger>
<Accordion.Content>
<Text>Sous trois jours ouvrés.</Text>
</Accordion.Content>
</Accordion.Item>
</Accordion>
A stringifiable child of the trigger is wrapped in a Text for you (R3) and takes the
type the recipe put on the row. Anything else is yours to place — the trigger is a row of
views, and a bare string in one is a crash on React Native.
Several at a time
<Accordion selectionMode="multiple" defaultValue={['a', 'b']}>
…
</Accordion>
The value becomes a list, in the order the rows were opened.
Always one open
<Accordion defaultValue="a" isCollapsible={false}>
…
</Accordion>
Pressing the open row refuses rather than closing it, and onValueChange never fires for
a change that did not happen. That is a set of tabs wearing an accordion.
A row that paints its own state
<Accordion.Item value="a">
{({ isExpanded }) => (
<>
<Accordion.Trigger>
<Text style={{ color: isExpanded ? accent : foreground }}>Facturation</Text>
<Accordion.Indicator />
</Accordion.Trigger>
<Accordion.Content>…</Accordion.Content>
</>
)}
</Accordion.Item>
The escape hatch for a row whose whole appearance changes when it opens, without wiring
useAccordionItem yourself.
Controlled
const [value, setValue] = useState('a')
<Accordion value={value} onValueChange={next => setValue(next as string)}>…</Accordion>
Which half is controlled is decided on the first render and then held — a component that changes hands mid-life produces a bug nobody can read from the call site.
Style as props
<Accordion borderRadius={8} />
<Accordion.Trigger paddingVertical={20} />
<Accordion.Content paddingBottom={24} />
Full RN names, full RN values (R14). Every node takes them.
Everything else goes through style
A gradient container, a tinted shadow, a transform — style is the last word.
Root props
AccordionProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| children | ReactNode | No | — |
| variant | AccordionVariant | undefined | No | — |
| size | Size | undefined | No | — |
| radius | RadiusKey | undefined | No | — |
| color | string | undefined | No | The tint (R7) — a raw value, never a token. |
| selectionMode | AccordionSelectionMode | undefined | No | — |
| value | AccordionValue | undefined | No | Controlled. Leave unset and the root owns it. |
| defaultValue | AccordionValue | undefined | No | — |
| onValueChange | ((value: AccordionValue) => void) | undefined | No | — |
| isDisabled | boolean | undefined | No | — |
| isCollapsible | boolean | undefined | No | Whether an open item can be closed by pressing it again. Off, the accordion always has one item open — which is what a set of tabs pretending to be an accordion needs. |
| hasSeparator | boolean | undefined | No | The hairline between rows. It is the only thing separating them in `ghost`. |
| asChild | boolean | undefined | No | — |
Slots
AccordionContentProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| children | ReactNode | No | — |
AccordionIndicatorProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| as | ComponentType<IconComponentProps> | undefined | No | The glyph. Defaults to the chevron the library ships. |
| size | number | undefined | No | — |
| color | string | undefined | No | A raw value (R7), never a token. |
AccordionItemProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| value | string | Yes | What identifies this row in the root's value. |
| isDisabled | boolean | undefined | No | — |
| children | ReactNode | ((state: AccordionItemRenderState) => ReactNode) | No | — |
AccordionTriggerProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| children | ReactNode | No | — |
| asChild | boolean | undefined | No | — |
Variants, sizes and colour
The four levels
| ours | The reference implementation | fill |
|---|---|---|
primary | surface | surface |
secondary | — | surfaceSecondary |
tertiary | — | a border, no fill |
ghost | default | none — the default |
The tokens are the Card's — an accordion in primary is a card with rows in it, and
two containers that look alike but are declared apart drift. Only the names differ, and
they differ on purpose: the ladder descends in one direction, which default sitting in
the middle of the Card's order does not.
Accessibility
The trigger is a button carrying expanded, which is the only thing telling a screen
reader whether the panel under it is open: the chevron says nothing out loud.
Migration from legacy
The legacy components are ExpansionPanel and ExpansionPanelItem.
| Legacy | v1 |
|---|---|
<ExpansionPanel> | <Accordion> |
<ExpansionPanelItem> | <Accordion.Item value="…"> |
title="…" | a stringifiable child of <Accordion.Trigger> |
the item's children | <Accordion.Content> |
isExpanded per item | value / defaultValue on the root |
allowMultiple | selectionMode="multiple" |
themeColor="primary" | color={theme.colors.accent} |
customAppearance={{ … }} | style on the slot that key named |
The open state moves to the root. Legacy asked each item whether it was open, which is
what made "only one at a time" the caller's problem. One value on the container is what
selectionMode needs to mean anything.
Implementation notes
Motion
The height is never measured. The panel is mounted or it is not, and Reanimated's
layout transition animates the row between the two — LinearTransition.springify() on
The reference implementation's numbers, damping 140 against stiffness 1600. Stiffer than the chevron's 1000
on purpose: a height is a longer distance than a rotation, and at the chevron's stiffness
the same damping makes a long panel take almost half a second to settle.
Measuring it ourselves would mean a hidden pass on every open, and a panel whose content
grows afterwards — an image loading, a list filling — would be stuck at the height it had
when it was measured. overflow: 'hidden' on the row is what turns the mounted panel into
one unrolling rather than content drawn outside its row from the first frame.
The container carries the same transition. Without it the accordion's own height jumps to its new total in one frame while the rows inside it are still animating.
The content fades, 200 ms each way. The height is already moving underneath it, and two things travelling at once reads as the panel fighting itself.
The chevron turns 0 → −180° on the Select's spring — damping 140, stiffness 1000,
mass 4. It is a worklet, so it keeps turning while the panel's content mounts.
The corner
It moves with size, as the Card's does, and sits one level below the Card's at every
step:
size | corner | the Card at the same size |
|---|---|---|
xs | 9 | 12 |
sm | 12 | 18 |
md | 18 | 24 |
lg | 24 | 36 |
A card wraps its content with padding on all four sides, so a large corner curves through empty space. An accordion's rows run edge to edge, and the same corner curves through the first and last row's own text.
radius overrides it, on both layers at once.
Two layers on the root
The root renders one view inside itself, and it is the only place in the library where a
root does. A single layer cannot both cast a shadow and clip its children on iOS —
overflow: 'hidden' sets masksToBounds, which takes the layer's own shadow with it — and
this component needs both: primary is lifted, and a pressed row has to be cut against the
card's rounded corner rather than painting over it, square, for as long as a finger is on
it.
So the outer layer carries the shadow, the border and the corner; the inner one carries the same corner and the clip. Both take the layout transition, because without it on the outer one the accordion's height jumps to its new total a frame ahead of the rows inside it.
The separators
Drawn by the root, between its children — never by a row. A row that drew its own would draw one under the last item too, and every accordion would start by hiding it.
They are built from Children.toArray, which drops nulls, so a conditionally rendered row
cannot leave a hairline hanging where nothing is.
In primary, secondary and tertiary the container is inset from its own edge and the
separators are inset with it. ghost has no edge to be inset from, so its rows run the
full width and the hairline runs with them — the difference between a list on a page and a
list in a box.