A card whose body folds under its header, with the note and actions always in view.
Folding
The title is the toggle. Press it, or focus it and press Enter or Space, to fold or open the body. The chevron at the end of the title row turns toward the title while the card is folded. The header stays put, and the body folds away in 200ms, or at once under reduced motion.
Example
Example
Where we reach you, and how often.
Product updates, when something ships.
A weekly digest, every Tuesday morning.
Paid by card, every month.
import { Button, CollapsibleCard } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function CollapsibleCardBasic(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: 480 }}>
<CollapsibleCard title="Notifications" level={3} note="Where we reach you, and how often.">
<p>Product updates, when something ships.</p>
<p>A weekly digest, every Tuesday morning.</p>
<div className="row">
<Button variant="secondary">Send a test email</Button>
</div>
</CollapsibleCard>
<CollapsibleCard title="Billing" level={3} note="Paid by card, every month." defaultExpanded={false}>
<p>The next invoice is on the first of the month.</p>
</CollapsibleCard>
</div>
);
}Actions
Controls in actions sit at the end of the header row, outside the toggle. They stay usable while the card is folded, so a Reset button never hides with the fields it resets. The title and note keep at least 12rem. When the row is too narrow for that, the actions move under the note.
Example
Example
Fold the card, and Reset is still there.
Reset puts both back as they were saved.
'use client';
import { Button, CollapsibleCard, Switch } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
const SAVED = { updates: true, digest: false };
export default function CollapsibleCardActions(): ReactElement {
const [draft, setDraft] = useState(SAVED);
const isDirty = draft.updates !== SAVED.updates || draft.digest !== SAVED.digest;
return (
<CollapsibleCard
title="Email"
level={3}
note="Fold the card, and Reset is still there."
actions={
<Button
variant="secondary"
isDisabled={!isDirty}
onPress={() => {
setDraft(SAVED);
}}
>
Reset
</Button>
}
style={{ width: '100%', maxWidth: 480 }}
>
<Switch
isSelected={draft.updates}
onChange={(updates) => {
setDraft({ ...draft, updates });
}}
isChanged={draft.updates !== SAVED.updates}
>
Product updates
</Switch>
<Switch
isSelected={draft.digest}
onChange={(digest) => {
setDraft({ ...draft, digest });
}}
isChanged={draft.digest !== SAVED.digest}
>
Weekly digest
</Switch>
<p style={{ margin: 0, color: 'var(--color-slate)' }}>Reset puts both back as they were saved.</p>
</CollapsibleCard>
);
}Remembering the fold
With a storageKey, the card remembers its fold in this browser across reloads. Cards with the same key fold together, on the page and in other tabs. All remembered cards share one localStorage entry, snapshot-ui:collapsed, so give each card a unique key, such as its page and title.
- The entry lists only folded keys, and an unstored key means open. So
defaultExpanded={false}does not apply under astorageKey. - A card controlled by
isExpandedreads and stores nothing. onExpandedChangeis called when someone uses this card, by its toggle or by find-in-page. A card that is only syncing with another card or tab does not call it.- A card that find-in-page opens is remembered as open.
- If the browser blocks storage or refuses a write, the card still folds, but the fold lasts only while a card with a
storageKeyis on the page.
Example
Example
Fold it and reload the page: it stays folded.
Enter or Space on the title folds the card or opens it.
Tab moves on from the title, and a folded body takes no focus.
import { CollapsibleCard } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function CollapsibleCardRemembered(): ReactElement {
return (
<CollapsibleCard
title="Shortcuts"
level={3}
note="Fold it and reload the page: it stays folded."
storageKey="docs-collapsible-card-shortcuts"
style={{ width: '100%', maxWidth: 480 }}
>
<p>Enter or Space on the title folds the card or opens it.</p>
<p>Tab moves on from the title, and a folded body takes no focus.</p>
</CollapsibleCard>
);
}The server cannot read the browser's storage, so a server-rendered card is drawn open and folds at once, with no transition, when the page hydrates. To get the first frame right, control the card with isExpanded from state the server can read, such as a cookie.
The changed marker
While a card is folded, a clay dot after its title says its body holds a change. The card finds changes itself: any element in its body with data-changed marks it. Every Snapshot UI field's root carries data-changed while its isChanged is true, and a changed field in a nested card counts too. For a change no field reports, pass isChanged to the card. Its root then carries data-changed as well, so an outer collapsible card counts it. An open card shows no marker, since each field shows its own Changed chip.
Example
Example
Folded over a changed field, the card marks the change after its title.
'use client';
import { Button, CollapsibleCard, TextField } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
const SAVED = 'Northwind Studio';
export default function CollapsibleCardChanged(): ReactElement {
const [name, setName] = useState('Northwind Design');
return (
<CollapsibleCard
title="Workspace"
level={3}
note="Folded over a changed field, the card marks the change after its title."
defaultExpanded={false}
actions={
<Button
variant="secondary"
isDisabled={name === SAVED}
onPress={() => {
setName(SAVED);
}}
>
Reset
</Button>
}
style={{ width: '100%', maxWidth: 480 }}
>
<TextField label="Workspace name" value={name} onChange={setName} isChanged={name !== SAVED} />
</CollapsibleCard>
);
}- Screen readers: the toggle reads its title, then collapsed, then
changedLabel("Changed" by default). The dot itself is hidden from them. - Layout: a folded title keeps room for the dot, so the title does not shift when it appears.
- Timing: the card checks its body as it folds and watches it while folded, so a field changed while open marks it at once.
- Server rendering: the dot for a changed field appears after hydration. A card drawn folded with
isChangedshows its dot from the first frame.
Opening for an invalid field
A folded card opens itself when a field in its body fails the browser's validation, on submit or on a call to the form's checkValidity() or reportValidity(). A field among the header's actions is already in view, so it leaves the card folded.
The card opens the way its toggle does: a remembered fold is forgotten, and onExpandedChange is called once with true, however many fields fail. A card controlled by isExpanded opens once your app sets it. The open has no transition, so the body is full height when the field takes focus and the browser can scroll it into view. This works for React Aria fields and for plain inputs with required.
This needs browser validation, which React Aria uses by default (validationBehavior="native"). With validationBehavior="aria", the browser fires no invalid event and the card stays folded. Open it yourself with isExpanded when your validation fails.
Usage
A card starts open. Pass defaultExpanded={false} to start it folded, or control it with isExpanded and onExpandedChange. The children are the body, laid out like a card's body. CollapsibleCard is a client component that a server component can render, as the first example does. For a card that never folds, use a Card, which is static and ships no JavaScript. A folded card holds only its header, so in a shrink-to-fit parent its width can change. Give it a width from its container.
import { Button, CollapsibleCard } from '@snapshot-ui/react';
export function Notifications() {
return (
<CollapsibleCard
title="Notifications"
note="Where we reach you, and how often."
actions={<Button variant="secondary">Reset</Button>}
>
<p>Product updates, when something ships.</p>
</CollapsibleCard>
);
}Props
Other HTML attributes, className and ref go on the card's root. className is merged after the library's classes.
actions ReactNode | Controls at the end of the header row, such as a Reset button. They are never inside the toggle, and they stay visible and usable while the card is folded. |
|---|---|
as "article" | "div" | "section" | The element to render. A Default: 'div' |
changedLabel string | The words a screen reader reads with the toggle while the folded marker shows. Default: 'Changed' |
className string | |
defaultExpanded boolean | Whether the card is open on the first render, when neither Default: true |
isChanged boolean | Marks a folded card as changed when no field in its body reports it with |
isExpanded boolean | Whether the card is open, for a controlled card. Pair it with |
level 1 | 2 | 3 | 4 | The heading element of the title, Default: 2 |
note ReactNode | The line under the title, drawn as a card note. It stays visible while the card is folded. |
onExpandedChange ((isExpanded: boolean) => void) | Called with the new state when someone folds or opens this card, by its toggle or by find-in-page. Not called when the card only syncs with another card or tab. |
storageKey string | Remembers the fold in this browser under this key. Cards with the same key fold together, across
tabs. Ignored when |
title ReactNode | The title, drawn as the card title. It is the toggle's text and its accessible name. |
Accessibility
The title is a heading that holds a button, so the card appears in a screen reader's list of headings. The button reads the title, then expanded or collapsed. The chevron is decoration. The toggle covers the title and note, is at least 40px tall, and never contains the actions. A folded body leaves the tab order and the accessibility tree. When a card folds while focus is in its body, such as after a Done button, focus moves to its toggle.
| EnterSpace | Folds or opens the card. |
|---|---|
| Tab | Moves to the toggle, then to the actions. A folded body takes no focus. |
Find-in-page:
- In browsers that support
hidden="until-found", find-in-page still searches a folded body. The card opens at once so the browser can scroll to the match, and reports it throughonExpandedChange. - In other browsers, a folded body is hidden completely and not searched.
- Text fragment links into a card folded at load are unreliable. The server's HTML hides the body completely until hydration. In our measurements Firefox never looked again, and Chromium and WebKit stopped looking within about 1.5 and 5 seconds. Leave open a card that a link must reach.
A card is a div unless as says otherwise. A section named by aria-label, or by aria-labelledby pointing into the title, is a region landmark. An article is not a landmark, named or not.
Design rules
What keeps collapsible cards in the system.
Title the card with what it holds, such as Notifications. The title is the toggle and its name.
Put a control that acts on the whole card, such as Reset, in actions, so it stays in reach while folded.
Leave open what a reader needs on arrival. Fold what they return to now and then.
Never put a link or a button in the title. The title is already a button.
Never fold the only card on a page. That hides the page.
Never use a collapsible card for a single control or a short label. A card holds a section.
Never use a collapsible card for a card that never folds. Use a Card, which is static.