Morphing Dialog
Click a card and it scales and morphs seamlessly into a centered dialog with a shared-element animation, then shrinks back to the original card position on close.
npx shadcn@latest add https://webberui.com/r/morphing-dialog.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<MorphingDialog />
Installation
npx shadcn@latest add https://webberui.com/r/morphing-dialog.jsonOr, once registries are configured in components.json, install it as @webberui/morphing-dialog.
Usage
import {
MorphingDialog,
MorphingDialogTrigger,
MorphingDialogContent,
MorphingDialogClose,
} from "@/components/ui/morphing-dialog";
<MorphingDialog>
<MorphingDialogTrigger className="w-52 rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<h3 className="text-sm font-semibold">Card title</h3>
<p className="text-xs text-neutral-500">Click to expand for more</p>
</MorphingDialogTrigger>
<MorphingDialogContent className="max-w-sm p-6">
<MorphingDialogClose />
<h3 className="text-lg font-semibold">Dialog title</h3>
<p className="pt-2 text-sm text-neutral-600 dark:text-neutral-300">
Once the card has scaled up and morphed, it can carry fuller content.
</p>
</MorphingDialogContent>
</MorphingDialog>;MorphingDialogTrigger renders as the clickable card container, and MorphingDialogContent is the dialog that appears centered once opened. The two share the same layoutId, so on click the card scales up and morphs seamlessly into the dialog, then shrinks back to the original card position on close.
Pass open and onOpenChange to switch to controlled mode:
const [open, setOpen] = React.useState(false);
<MorphingDialog open={open} onOpenChange={setOpen}>
{/* ... */}
</MorphingDialog>;Props
MorphingDialog
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Content; must include MorphingDialogTrigger and MorphingDialogContent |
open | boolean | — | Open state for controlled mode; when omitted, the component manages it internally |
onOpenChange | (open: boolean) => void | — | Callback fired when the open state changes (in both controlled and uncontrolled modes) |
MorphingDialogTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Card content |
className | string | — | Appended to the card container className |
MorphingDialogContent
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Dialog content |
className | string | — | Appended to the dialog container className |
MorphingDialogClose
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | lucide-react X icon | Custom button content |
className | string | — | Appended to the close button className |
How it works
- layoutId FLIP:
MorphingDialogTriggerandMorphingDialogContentshare a singlelayoutIdproduced byuseId. On open the content mounts, Motion snapshots the card's bounding box, and the dialog transitions from the card's position to its final centered position via transform (FLIP); on close the content exits insideAnimatePresence, handing the position back to the still-mounted card and morphing in reverse back to where it started. - Content crossfade: what is shared is the outer frame — the inner content of the card and the dialog can be completely different. Motion automatically crossfades two elements that share a
layoutId, so the two sides fade across each other during the morph. - Proportional distortion: FLIP interpolates between two boxes of different sizes with scale, so content and corner radii distort temporarily during the morph and snap back to exact layout once the animation ends. This is inherent to shared-element animation; keeping the aspect ratio and corner radius of the card and the dialog close together reduces the sense of distortion.
- Portal to body: the dialog and the overlay are rendered into
document.bodyviacreatePortal, so an ancestor'soverflowclipping or atransformestablishing a containing block cannot affectfixedpositioning and centering. - Synchronized exit: the overlay's fade-out duration matches the layout morph duration, so on close the dialog shrinking back and the overlay fading out finish together, with no beat lost in the handoff.
- Controlled mode: passing
openswitches to controlled mode and the component stops touching its internal state;onOpenChangefires in both controlled and uncontrolled modes, so it can be used to observe open/close events.
Accessibility
- The content carries
role="dialog"andaria-modal="true"; focus moves into the dialog on open (tabIndex={-1}), and focus returns to the original card after close - The card is
role="button"withtabIndex={0}, opens on Enter / Space, and carriesaria-haspopup="dialog"plus anaria-expandedstate - Esc or a click on the overlay closes it; while open,
bodyis set tooverflow: hiddento lock background scrolling and restored to its previous value on close - When the user has "reduce motion" enabled at the system level, the layout offset drops to zero and the morph degrades to a quick fade in / fade out with no sense of movement
- The component takes the lightweight route and does not build in a focus trap; if the dialog holds several interactive elements and needs a complete keyboard focus cycle, pair it with a focus trap solution of your own
Group Buy Progress (React)
A head-count group-buy card for React: stacked participant avatars, tiered group prices, a deadline countdown, a CTA that flips to "group formed" with a card pulse and confetti the moment the threshold is hit, and an automatic refund note if the deadline passes short of the minimum.
Drawer
A panel that slides in from the edge of the screen, supporting all four directions, click-the-overlay to close, and single-axis drag to swipe it away.