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

Where we reach you, and how often.

Product updates, when something ships.

A weekly digest, every Tuesday morning.

Paid by card, every month.

Follows the page theme and corners
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

Fold the card, and Reset is still there.

Changed
Changed

Reset puts both back as they were saved.

Follows the page theme and corners
'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 a storageKey.
  • A card controlled by isExpanded reads and stores nothing.
  • onExpandedChange is 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 storageKey is on the page.

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.

Follows the page theme and corners
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

Folded over a changed field, the card marks the change after its title.

Follows the page theme and corners
'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 isChanged shows 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 section with an accessible name is a region landmark. An article is not a landmark, named or not, and a div is neither.

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 isExpanded nor storageKey decides it.

Default: true

isChanged
boolean

Marks a folded card as changed when no field in its body reports it with data-changed. The marker is a clay dot after the title, described by changedLabel. The root then carries data-changed, so an outer card counts it.

isExpanded
boolean

Whether the card is open, for a controlled card. Pair it with onExpandedChange. It wins over storageKey, and the card then reads and stores nothing.

level
1 | 2 | 3 | 4

The heading element of the title, h1 to h4, so the card fits the page's outline. The look is the same at every level.

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 isExpanded is given, and defaultExpanded={false} does not apply. A server-rendered card is drawn open and folds on hydration.

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.

EnterSpaceFolds or opens the card.
TabMoves 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 through onExpandedChange.
  • 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.

esc

↑ ↓ to move↵ to open