BetaNavigation

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. Carries expanded for 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.

PropTypeRequiredDescription
childrenReactNodeNo
variantAccordionVariant | undefinedNo
sizeSize | undefinedNo
radiusRadiusKey | undefinedNo
colorstring | undefinedNoThe tint (R7) — a raw value, never a token.
selectionModeAccordionSelectionMode | undefinedNo
valueAccordionValue | undefinedNoControlled. Leave unset and the root owns it.
defaultValueAccordionValue | undefinedNo
onValueChange((value: AccordionValue) => void) | undefinedNo
isDisabledboolean | undefinedNo
isCollapsibleboolean | undefinedNoWhether 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.
hasSeparatorboolean | undefinedNoThe hairline between rows. It is the only thing separating them in `ghost`.
asChildboolean | undefinedNo

Slots

AccordionContentProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

AccordionIndicatorProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
asComponentType<IconComponentProps> | undefinedNoThe glyph. Defaults to the chevron the library ships.
sizenumber | undefinedNo
colorstring | undefinedNoA 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.

PropTypeRequiredDescription
valuestringYesWhat identifies this row in the root's value.
isDisabledboolean | undefinedNo
childrenReactNode | ((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.

PropTypeRequiredDescription
childrenReactNodeNo
asChildboolean | undefinedNo

Variants, sizes and colour

The four levels

oursThe reference implementationfill
primarysurfacesurface
secondarysurfaceSecondary
tertiarya border, no fill
ghostdefaultnone — 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.

Legacyv1
<ExpansionPanel><Accordion>
<ExpansionPanelItem><Accordion.Item value="…">
title="…"a stringifiable child of <Accordion.Trigger>
the item's children<Accordion.Content>
isExpanded per itemvalue / defaultValue on the root
allowMultipleselectionMode="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:

sizecornerthe Card at the same size
xs912
sm1218
md1824
lg2436

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.