WebberUI

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.

Loading preview…
npx shadcn@latest add https://webberui.com/r/morphing-dialog.json

Playground

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.json

Or, 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

PropTypeDefaultDescription
childrenReact.ReactNodeContent; must include MorphingDialogTrigger and MorphingDialogContent
openbooleanOpen state for controlled mode; when omitted, the component manages it internally
onOpenChange(open: boolean) => voidCallback fired when the open state changes (in both controlled and uncontrolled modes)

MorphingDialogTrigger

PropTypeDefaultDescription
childrenReact.ReactNodeCard content
classNamestringAppended to the card container className

MorphingDialogContent

PropTypeDefaultDescription
childrenReact.ReactNodeDialog content
classNamestringAppended to the dialog container className

MorphingDialogClose

PropTypeDefaultDescription
childrenReact.ReactNodelucide-react X iconCustom button content
classNamestringAppended to the close button className

How it works

  • layoutId FLIP: MorphingDialogTrigger and MorphingDialogContent share a single layoutId produced by useId. 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 inside AnimatePresence, 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.body via createPortal, so an ancestor's overflow clipping or a transform establishing a containing block cannot affect fixed positioning 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 open switches to controlled mode and the component stops touching its internal state; onOpenChange fires in both controlled and uncontrolled modes, so it can be used to observe open/close events.

Accessibility

  • The content carries role="dialog" and aria-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" with tabIndex={0}, opens on Enter / Space, and carries aria-haspopup="dialog" plus an aria-expanded state
  • Esc or a click on the overlay closes it; while open, body is set to overflow: hidden to 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

On this page