Motion UIComponentsComponent

Expand card

An App Store-style expand where a grid tile morphs into a centred detail panel.

npx shadcn@latest add @motion/expand-card

API reference

Components

<ExpandCards />

The provider/group for a set of expanding cards. It owns which card is open (openId), the reduced-motion decision every part reads, and the stable layoutId prefix that lets several groups coexist on one page. Renders no DOM of its own - drop it around your grid and its single ExpandCardPanel.

children?

ReactNode

Your grid of ExpandCardTriggers and the single ExpandCardPanel.

scrimOpacity?

number = 0.72

Scrim opacity behind an open card (0-1). A visual constant, not global feel, so it stays a prop rather than a theme token. Defaults to 0.72.

<ExpandCardTrigger />

A grid tile that expands on activation. Renders a real <button> (correct keyboard + screen-reader semantics, aria-haspopup="dialog" and a live aria-expanded) wrapping the morphing surface that carries the shared layoutId. Hover/focus feedback is discrete colour only - style it via group-hover:*/group-focus-visible:* in className, the group class is already on the button.

id

string

The card's id - matched against openId and used to build the shared layoutIds so this tile morphs into the panel opened for the same id.

children?

ReactNode

Your card's visible content (an ExpandCardShared icon, a heading, a summary). Rendered inside the morphing surface.

className?

string

Merged onto the morphing surface - style the card face here (bg-card, border, padding, group-hover:* feedback, ...).

"aria-label"?

string

The button's accessible name (the tile's text is often abbreviated, so give the whole "Title. Summary. Open for detail." here).

<ExpandCardShared />

Marks a sub-element that morphs in tandem with its card's surface. It reads the card id from context (the trigger's own id, or the open panel's id), so you render the same part in both places and it flies between them. Under reduced motion it drops the layoutId and simply renders in place.

part

string

A stable name for this morphing part (e.g. "icon", "head"). Render one with the same part inside both the trigger and the panel and Motion flies it between them.

as?

"span" | "div" = "span"

Which element to render. Defaults to "span".

layout?

"position" | "size" | boolean

Pass "position" to animate only the element's position during the morph (never its size), which stops a text block from being scale-stretched while the surface resizes. Ignored under reduced motion.

className?

string

Merged onto the element - size and style the part here. The trigger and panel can style the same part differently; the morph handles the delta.

children?

ReactNode

The part's content.

<ExpandCardPanel />

The expanded detail dialog. There is exactly one per ExpandCards group; it renders nothing until a card opens, then mounts inside an AnimatePresence so it morphs in from the trigger and back out on close. It composes the shared overlay primitives - useFocusTrap (Tab cycle, Escape, focus restore to the trigger), useScrollLock (background scroll freeze) and Backdrop (the click-out scrim) - so the accessibility plumbing lives in one place, and wraps your content in a layout="position" element so the morph never distorts it.

children

((card: { id: string }) => ReactNode) | ReactNode

The dialog body. A render function receiving the open card's id (so you can look up its content and build matching element ids) is the usual form; a plain node works when the body does not vary by card.

className?

string

Merged onto the dialog surface - the morphing box (bg-card, border, max-w-*, max-h-*, overflow-hidden, ...).

contentClassName?

string

Merged onto the inner layout="position" wrapper that holds your content (padding, gap, overflow-y-auto, ...). Kept separate from the surface so the position-only layout trick can sit between them.

labelledBy?

(id: string) => string

Builds the dialog's aria-labelledby from the open card's id - point it at the id you set on your title element.

describedBy?

(id: string) => string

Builds the dialog's aria-describedby from the open card's id.

initialFocusRef?

RefObject<HTMLElement | null>

Where focus lands when the dialog opens. Defaults to the first focusable descendant (typically your close button); pass your close button's ref to be explicit.

<ExpandCardBody />

The dialog's non-shared body region. It cross-fades in AFTER the surface morph settles (delayed by the snap transition's own duration) so the detail text never appears while the box is still resizing. Under "calm" it fades in without travel; under "still" it appears instantly.

children?

ReactNode

The dialog's non-shared detail content.

className?

string

Merged onto the wrapper.

Hooks

useExpandCard

Read the expand/collapse controls from inside an ExpandCards group - use it to wire a dialog close button (onClick={close}) or drive the group from your own affordance.

useExpandCard(): UseExpandCard

Returns

openId

string | null

The id of the currently expanded card, or null.

open

(id: string) => void

Expand the card with this id.

close

() => void

Collapse the open card.

isOpen

(id: string) => boolean

Whether the card with this id is the one currently expanded.