Morphing dialog
Free · MITA service card expands into a booking dialog, with its image, title and price flying into place through a shared-layout spring, then folds back into the card on Esc or backdrop click.
Installation
$ npx shadcn@latest add https://goodui.dev/r/morphing-dialog.jsonThe file lands in components/goodui/morphing-dialog.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/morphing-dialog works too.
Usage
import * as React from "react"
import { CalendarCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
MorphingDialog,
MorphingDialogClose,
MorphingDialogContainer,
MorphingDialogContent,
MorphingDialogDescription,
MorphingDialogMedia,
MorphingDialogSubtitle,
MorphingDialogTitle,
MorphingDialogTrigger,
} from "@/components/goodui/morphing-dialog"
export function ServiceCard() {
const [open, setOpen] = React.useState(false)
return (
<MorphingDialog open={open} onOpenChange={setOpen}>
<MorphingDialogTrigger className="max-w-xs shadow-xs hover:shadow-md">
<MorphingDialogMedia className="h-24 bg-gradient-to-br from-amber-300 to-rose-400" />
<span className="block p-4">
<MorphingDialogTitle>Balayage and gloss</MorphingDialogTitle>
<MorphingDialogSubtitle>2 h 30 min · $185</MorphingDialogSubtitle>
</span>
</MorphingDialogTrigger>
<MorphingDialogContainer>
<MorphingDialogContent>
<MorphingDialogClose />
<MorphingDialogMedia className="h-40 shrink-0 bg-gradient-to-br from-amber-300 to-rose-400" />
<div className="overflow-y-auto p-6">
<MorphingDialogTitle>Balayage and gloss</MorphingDialogTitle>
<MorphingDialogSubtitle>2 h 30 min · $185</MorphingDialogSubtitle>
<MorphingDialogDescription className="mt-4 space-y-4">
<p className="text-sm text-muted-foreground">
Hand-painted highlights finished with a toning gloss and blow-dry. Includes a 10 minute consultation.
</p>
<Button className="w-full" onClick={() => setOpen(false)}>
<CalendarCheck /> Book Tue 10:00 AM
</Button>
</MorphingDialogDescription>
</div>
</MorphingDialogContent>
</MorphingDialogContainer>
</MorphingDialog>
)
}When to use
- A grid of cards (services, plans, products, team members) where opening one should show its details without losing the user's place.
- Detail views that reuse the card's own image, title and price, so the move from card to dialog explains itself.
- Booking or checkout steps that start from a card: pick a time, confirm, close back into the card.
- Confirmations and plain forms opened from a button: a regular shadcn Dialog is clearer; there is no card to morph from.
- Long forms or lists on phones: use drawer-spring, which gives a bottom sheet with snap points.
- A small form tied to one button (add a note, apply a code): use popover-morph, which stays non-modal.
| If you need | Use |
|---|---|
| A card opens into a full detail dialog | morphing-dialog |
| A card grows in place, inside the page flow | expandable-card |
| A button opens a small floating form | popover-morph |
| Mobile-first sheet with half and full heights | drawer-spring |
Examples
Focus a specific control when it opens
<MorphingDialogDescription>
<input data-autofocus placeholder="Promo code" className="h-9 w-full rounded-md border px-3" />
</MorphingDialogDescription>Slower, bouncier morph and a translated close button
<MorphingDialog transition={{ type: "spring", bounce: 0.25, duration: 0.6 }}>
{/* MorphingDialogTrigger as above */}
<MorphingDialogContainer>
<MorphingDialogContent>
<MorphingDialogClose labels={{ close: "Schließen" }} />
{/* media, title, subtitle, description */}
</MorphingDialogContent>
</MorphingDialogContainer>
</MorphingDialog>API reference
MorphingDialog
| Prop | Type | Default |
|---|---|---|
| children* Content. | React.ReactNode | — |
| open Controlled open state. Pair with onOpenChange. | boolean | — |
| defaultOpen Initial open state when uncontrolled. | boolean | false |
| onOpenChange Called with the next state when the trigger, Esc, the backdrop or a close control opens or closes it. | (open: boolean) => void | — |
| transition Transition for the shared-layout morph. Reduced motion always snaps. | Transition | — |
MorphingDialogTrigger
Also accepts every <motion.button> attribute.
MorphingDialogContainer
| Prop | Type | Default |
|---|---|---|
| children* Content. | React.ReactNode | — |
| className Classes for the fixed layer that centers the dialog (padding, alignment). | string | — |
| backdropClassName Classes for the backdrop. | string | — |
MorphingDialogContent
Also accepts every <motion.div> attribute.
MorphingDialogMedia
Also accepts every <motion.div> attribute.
MorphingDialogTitle
| Prop | Type | Default |
|---|---|---|
| children* Content. | React.ReactNode | — |
| className Classes merged onto the root element. | string | — |
MorphingDialogSubtitle
| Prop | Type | Default |
|---|---|---|
| children* Content. | React.ReactNode | — |
| className Classes merged onto the root element. | string | — |
MorphingDialogDescription
Also accepts every <motion.div> attribute.
MorphingDialogClose
| Prop | Type | Default |
|---|---|---|
| labels Text for built-in controls and screen readers; pass translations here. | Partial<MorphingDialogLabels> | — |
Also accepts every <button> attribute.
Accessibility
| Enter / Space | On the card (a native <button>): opens the dialog. |
| Esc | Closes the dialog; focus returns to the card. |
| Tab / Shift+Tab | Cycles through the focusable elements inside the dialog; focus cannot leave it while open. |
- The trigger is a real <button> with aria-haspopup="dialog" and aria-expanded.
- The content has role="dialog" and aria-modal="true"; MorphingDialogTitle becomes its accessible name and MorphingDialogDescription its description.
- On open, focus moves to the first [data-autofocus] element, else the first focusable element, else the dialog itself. On close, focus goes back to the card.
- Page scroll is locked while the dialog is open.
- MorphingDialogClose without children gets aria-label from labels.close (default "Close").
Motion
- The card's surface, MorphingDialogMedia, MorphingDialogTitle and MorphingDialogSubtitle share layoutIds, so each flies from its place in the card to its place in the dialog on a spring (bounce 0.12, 0.42 s). Override it with the transition prop.
- The backdrop fades in and out over 200 ms. MorphingDialogDescription fades up 10px after an 80 ms delay (250 ms) and fades out in 80 ms on close.
- The card stays above its neighbours until it has flown back into place.
- Reduced motion: the morph snaps (duration 0) and the description appears without moving; only the backdrop still fades.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Put the same MorphingDialogMedia, MorphingDialogTitle and MorphingDialogSubtitle in both the trigger and the content; parts that only exist on one side do not morph.
- The trigger is a <button>: lay out its text with <span> elements (as the dialog parts do) and never nest another button or a link in it.
- Set corner radius with style={{ borderRadius }} on the trigger or content, not a rounded-* class, or the corners distort during the morph.
- The dialog is position: fixed without a portal. Do not render it inside an element with transform or filter.
- Always include MorphingDialogTitle in the content (it names the dialog) and MorphingDialogDescription for the detail body (aria-describedby points to it only while it is rendered).
FAQ
Can I have many cards on one page?
Yes. Wrap each card in its own MorphingDialog; layout ids are scoped per instance with useId.
How do I close it after an action?
Control it with open and onOpenChange and set open to false in your handler, or render MorphingDialogClose with your own children.
Related
A card expands in place on a spring to reveal details, such as a staff profile with bookable slots or a plan's full feature list.
A bottom sheet with half and full snap points plus side drawers on a spring; drag the handle and the release speed picks the snap point or closes it, while the backdrop fades with the drag.
A small button such as "Add note" morphs into a floating form panel, its label sliding into the header, and shrinks back into the button on save, Esc or an outside click.
Eight surfaces (elevated, glass, gradient border, neo, inset…) with shadow, padding, radius and accent stripes, plus hover layers (lift, 3D tilt, pointer spotlight, glow, scale) and a looping border beam.