Morph Expand Card
Click a card to morph it seamlessly into a centered detail panel via shared layout; only one card in a group is expanded at a time.
npx shadcn@latest add https://webberui.com/r/morph-expand-card.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<MorphExpandCard />
Installation
npx shadcn@latest add https://webberui.com/r/morph-expand-card.jsonOr, once registries are configured in components.json, install it as @webberui/morph-expand-card.
Usage
Each MorphExpandCard holds one MorphExpandCardSummary (the collapsed content) and one MorphExpandCardDetail (the expanded detail). When wrapped in a MorphExpandGroup, only one card in the group is expanded at a time.
import {
MorphExpandGroup,
MorphExpandCard,
MorphExpandCardSummary,
MorphExpandCardDetail,
} from "@/components/ui/morph-expand-card";
<MorphExpandGroup columns={2}>
<MorphExpandCard value="a" label="Nebula filter">
<MorphExpandCardSummary>
<div className="p-4 font-semibold">Nebula filter</div>
</MorphExpandCardSummary>
<MorphExpandCardDetail>
<div className="p-6">The full detail content…</div>
</MorphExpandCardDetail>
</MorphExpandCard>
</MorphExpandGroup>;A single card also works on its own (without a MorphExpandGroup), in which case it manages its own expand / collapse state.
Controlled mode
Pass value and onValueChange to MorphExpandGroup to control which card is expanded from outside:
const [open, setOpen] = React.useState<string | null>(null);
<MorphExpandGroup value={open} onValueChange={setOpen}>
{/* … */}
</MorphExpandGroup>;Custom close button
The detail panel provides a close button in the top-right corner by default. To place it yourself, pass hideDefaultClose to MorphExpandCardDetail and drop a MorphExpandCardClose anywhere in the content:
import { MorphExpandCardClose } from "@/components/ui/morph-expand-card";
<MorphExpandCardDetail hideDefaultClose>
<MorphExpandCardClose className="left-3" />
{/* … */}
</MorphExpandCardDetail>;Props
MorphExpandGroup
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | — | Controlled mode: the value of the currently expanded card (null collapses everything) |
defaultValue | string | null | null | Initial expanded value in uncontrolled mode |
onValueChange | (value: string | null) => void | — | Fires when the expanded / collapsed state changes |
columns | number | 2 | Number of grid columns (1–6) |
children | ReactNode | — | Put MorphExpandCard children here |
MorphExpandCard
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | auto-generated | Unique value for the card; the group uses it to decide which one to expand |
label | string | — | Accessible label, applied to both the trigger card and the detail dialog |
children | ReactNode | — | Must contain one MorphExpandCardSummary and one MorphExpandCardDetail |
MorphExpandCardDetail
| Prop | Type | Default | Description |
|---|---|---|---|
hideDefaultClose | boolean | false | Hide the default top-right close button (use a custom MorphExpandCardClose instead) |
children | ReactNode | — | Detail content shown while expanded |
MorphExpandCardClose
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | X icon | Custom button content |
How it works
- The trigger card and the detail panel share one
layoutId, and Motion's shared layout morphs the card's bounding box seamlessly into the panel, shrinking it back into place on collapse. - The detail panel is mounted onto
document.bodythroughcreatePortaland centered with fixed positioning, so the morph is unaffected by any parentoverflowor stacking context. - While expanded, the card in its original position is temporarily
visibility: hiddenso it does not ghost behind the morphing panel. - Expanding locks
bodyscrolling and restores it on collapse (or unmount); the panel is capped at85vhand scrolls internally when the content is too long.
Accessibility
- The trigger card is
role="button"and focusable, and supports Enter / Space to expand; the detail panel isrole="dialog"witharia-modal. - Focus moves into the panel on expand and focus returns to the trigger on collapse; Esc closes it, as does clicking the overlay.
- Passing
labelsets thearia-labelon both the card and the dialog. - When the user has "reduce motion" enabled, the morph offset drops to zero and is replaced by a brief fade in / fade out.