A card that opens into its dialog and folds back into the grid.
01Preview
Start from a template
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react motion03Usage
import {
MorphDialog, MorphDialogContent, MorphDialogDescription, MorphDialogFade,
MorphDialogImage, MorphDialogSubtitle, MorphDialogTitle, MorphDialogTrigger,
} from "@/components/ui/morph-dialog";
<MorphDialog>
<MorphDialogTrigger>
<MorphDialogImage><img src={t.cover} alt="" /></MorphDialogImage>
<MorphDialogTitle>Analytics dashboard</MorphDialogTitle>
<MorphDialogSubtitle>Next.js · 14 pages</MorphDialogSubtitle>
</MorphDialogTrigger>
<MorphDialogContent>
<MorphDialogImage><img src={t.cover} alt="" /></MorphDialogImage>
<MorphDialogTitle>Analytics dashboard</MorphDialogTitle>
<MorphDialogSubtitle>Next.js · 14 pages</MorphDialogSubtitle>
<MorphDialogFade>
<MorphDialogDescription>Charts, filters and CSV export…</MorphDialogDescription>
</MorphDialogFade>
</MorphDialogContent>
</MorphDialog>04Source
"use client";
import { Dialog } from "@base-ui/react/dialog";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Transition } from "motion/react";
import { createContext, use, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { useControllableState } from "@/lib/use-controllable-state";
import { ease, spring } from "@/lib/motion";
import { X } from "@/lib/icons";
type Ctx = {
id: string;
open: boolean;
layout: Transition;
reduce: boolean;
instant: boolean;
settling: boolean;
setSettling: (v: boolean) => void;
container?: Dialog.Portal.Props["container"];
};
const MorphContext = createContext<Ctx | null>(null);
// Shared parts render differently in the card and in the dialog.
const WhereContext = createContext<"trigger" | "content">("trigger");
const useMorph = () => {
const ctx = use(MorphContext);
if (!ctx) throw new Error("MorphDialog parts must be inside <MorphDialog>");
return ctx;
};
export type MorphDialogProps = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/**
* Render the dialog inside this element instead of over the whole page: the backdrop covers
* only the container and page scroll isn't locked. The container needs position: relative.
*/
container?: Dialog.Portal.Props["container"];
children: React.ReactNode;
};
export function MorphDialog({ open: openProp, defaultOpen = false, onOpenChange, container, children }: MorphDialogProps) {
const id = useId();
const reduce = !!useReducedMotion();
const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });
const [instant, setInstant] = useState(false);
const [settling, setSettling] = useState(false);
// Opening springs softly; closing folds back faster. Escape and reduced motion skip the morph.
const layout: Transition = reduce || instant ? { duration: 0 } : open ? spring.soft : spring.snappy;
return (
<MorphContext value={{ id, open, layout, reduce, instant, settling, setSettling, container }}>
<Dialog.Root
open={open}
modal={container ? "trap-focus" : true}
onOpenChange={(next, details) => {
setInstant(!next && details.reason === "escape-key");
setOpen(next);
}}
>
{children}
</Dialog.Root>
</MorphContext>
);
}
export type MorphDialogTriggerProps = Omit<Dialog.Trigger.Props, "className"> & { className?: string };
/** The card. Its surface, image, title and subtitle travel into the dialog and back. */
export function MorphDialogTrigger({ className, children, ...rest }: MorphDialogTriggerProps) {
const { id, open, layout, settling, setSettling } = useMorph();
return (
<WhereContext value="trigger">
<Dialog.Trigger
data-state={open ? "open" : "closed"}
className={cn(
"group/card relative flex flex-col rounded-xl p-1 text-left outline-none",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[scale] duration-150 active:scale-[0.98] active:duration-75",
// Above its neighbors while it flies back into place.
(open || settling) && "z-(--z-dialog)",
className,
)}
{...rest}
>
{open ? (
// The slot it left stays visible, so it's clear where the dialog will fold back to.
<span aria-hidden className="absolute inset-0 rounded-xl bg-fg/[0.03]" />
) : (
<motion.span
aria-hidden
layoutId={`${id}-surface`}
transition={layout}
onLayoutAnimationStart={() => setSettling(true)}
onLayoutAnimationComplete={() => setSettling(false)}
style={{ borderRadius: 12 }}
className="absolute inset-0 border border-line-2 bg-raised shadow-[var(--shadow)] transition-[border-color,background-color] duration-150 group-hover/card:border-fg-4"
/>
)}
<span className="relative flex flex-col">{children}</span>
</Dialog.Trigger>
</WhereContext>
);
}
export type MorphDialogContentProps = Omit<Dialog.Popup.Props, "className" | "render"> & {
className?: string;
/** Max width in px. The dialog is never wider than its container less 32px. */
width?: number;
closeLabel?: string;
};
export function MorphDialogContent({ className, width = 420, closeLabel = "Close", children, ...rest }: MorphDialogContentProps) {
const { id, open, layout, reduce, instant, container } = useMorph();
const popupRef = useRef<HTMLDivElement>(null);
const contained = container != null;
const fade = { duration: reduce ? 0.12 : 0.2, ease: ease.out };
return (
<AnimatePresence>
{open && (
<Dialog.Portal keepMounted container={container}>
<WhereContext value="content">
<Dialog.Backdrop
render={
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: fade }}
exit={{ opacity: 0, transition: { duration: instant ? 0 : 0.18, ease: ease.out } }}
/>
}
className={cn(contained ? "absolute" : "fixed", "inset-0 z-(--z-overlay) bg-overlay")}
/>
<Dialog.Viewport className={cn(contained ? "absolute" : "fixed", "inset-0 z-(--z-dialog) grid place-items-center p-4")}>
<Dialog.Popup
ref={popupRef}
initialFocus={popupRef}
className={cn("relative flex max-h-full w-full min-w-0 flex-col text-fg outline-none", className)}
style={{ maxWidth: width }}
{...rest}
>
<motion.div
aria-hidden
layoutId={`${id}-surface`}
transition={layout}
style={{ borderRadius: 16 }}
exit={{ opacity: instant ? 0 : 1, transition: { duration: 0 } }}
className="absolute inset-0 border border-line-2 bg-raised shadow-pop"
/>
<div className="relative flex min-h-0 flex-col p-1.5">{children}</div>
{/* Last in the DOM so Tab reaches the content first; first thing the eye finds, over the image. */}
<MorphDialogFade className="absolute right-3.5 top-3.5">
<Dialog.Close
aria-label={closeLabel}
className={cn(
"relative grid size-7 place-items-center rounded-full border border-line-2 bg-raised/90 text-fg-2 shadow-[var(--shadow)] outline-none",
"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
)}
>
<X size={14} />
</Dialog.Close>
</MorphDialogFade>
</Dialog.Popup>
</Dialog.Viewport>
</WhereContext>
</Dialog.Portal>
)}
</AnimatePresence>
);
}
type SharedProps = { className?: string; children?: React.ReactNode };
// A part that exists in both the card and the dialog under one layoutId. While the dialog
// is open, the card keeps an invisible copy so its size never changes.
function Shared({ name, radius, className, children, as = "div" }: SharedProps & { name: string; radius?: number; as?: "div" | "p" }) {
const { id, open, layout } = useMorph();
const where = use(WhereContext);
// Inside the card (a button) everything is phrasing content.
const Tag = where === "trigger" ? "span" : as;
if (where === "trigger" && open)
return (
<Tag aria-hidden className={cn(className, "invisible")}>
{children}
</Tag>
);
const M = motion[Tag];
return (
<M layoutId={`${id}-${name}`} transition={layout} style={radius != null ? { borderRadius: radius } : undefined} className={cn("block", className)}>
{children}
</M>
);
}
/** The image. Keep the same aspect ratio in the card and the dialog so it scales without stretching. */
export function MorphDialogImage({ className, children, aspect = "2 / 1" }: SharedProps & { aspect?: string }) {
const where = use(WhereContext);
return (
<Shared name="image" radius={where === "trigger" ? 8 : 10} className={cn("relative w-full shrink-0 overflow-hidden bg-frame", className)}>
<span className="block" style={{ aspectRatio: aspect }}>
{children}
</span>
</Shared>
);
}
export function MorphDialogTitle({ className, children }: SharedProps) {
const where = use(WhereContext);
const { id, layout } = useMorph();
if (where === "content")
return (
<Dialog.Title
render={(props) => <motion.h2 {...(props as HTMLMotionProps<"h2">)} layoutId={`${id}-title`} transition={layout} />}
className={cn("w-fit max-w-full text-[17px] font-medium leading-6 tracking-[-0.02em] text-fg text-balance", className)}
>
{children}
</Dialog.Title>
);
// w-fit keeps the box the size of the words, so it scales uniformly between sizes.
return (
<Shared name="title" className={cn("w-fit max-w-full truncate text-[13px] font-medium leading-5 tracking-[-0.01em] text-fg", className)}>
{children}
</Shared>
);
}
export function MorphDialogSubtitle({ className, children }: SharedProps) {
const where = use(WhereContext);
return (
<Shared name="subtitle" as="p" className={cn("w-fit max-w-full truncate leading-[18px] text-fg-3", where === "trigger" ? "text-[12px]" : "text-[12.5px]", className)}>
{children}
</Shared>
);
}
/** Everything that only exists in the dialog: it fades in once the shared parts are on their way. */
export function MorphDialogFade({ className, children }: SharedProps) {
const { reduce, instant } = useMorph();
return (
<motion.div
// Held back until the surface has nearly arrived, so nothing shows outside it mid-flight.
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: reduce ? 0.12 : 0.22, delay: reduce ? 0 : 0.16, ease: ease.out } }}
exit={{ opacity: 0, transition: { duration: instant ? 0 : 0.08 } }}
className={className}
>
{children}
</motion.div>
);
}
export type MorphDialogDescriptionProps = Omit<Dialog.Description.Props, "className"> & { className?: string };
export function MorphDialogDescription({ className, ...rest }: MorphDialogDescriptionProps) {
return <Dialog.Description className={cn("text-[13px] leading-5 text-fg-2 text-pretty", className)} {...rest} />;
}
export type MorphDialogCloseProps = Omit<Dialog.Close.Props, "className"> & { className?: string };
/** A close control of your own, e.g. a "Cancel" button in the dialog's actions. */
export function MorphDialogClose({ className, ...rest }: MorphDialogCloseProps) {
return <Dialog.Close className={className} {...rest} />;
}05Props
MorphDialog
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Uncontrolled initial state. |
| onOpenChange | (open: boolean) => void | — | Called when it opens or closes, by any means. |
| container | HTMLElement | RefObject | null | — | Open inside this element instead of over the page: the backdrop covers only the container and page scroll stays free. The container needs position: relative. |
MorphDialogTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | The card: a button whose surface, image, title and subtitle travel into the dialog. While open it leaves a faint slot where it will return. |
MorphDialogContent
| Prop | Type | Default | Description |
|---|---|---|---|
| width | number | 420 | Maximum width in px; never wider than the viewport or container less 32px. |
| closeLabel | string | "Close" | Accessible name of the close button over the image. |
MorphDialogImage
| Prop | Type | Default | Description |
|---|---|---|---|
| aspect | string | "2 / 1" | Aspect ratio, identical in the card and the dialog so the image scales without stretching. |
| children | ReactNode | — | An img, video poster or any drawing; it fills the box. |
MorphDialogTitle / MorphDialogSubtitle
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Shared text. In the dialog the title is the dialog's accessible name. Both read best on one line in both places. |
MorphDialogFade
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Wrap everything that exists only in the dialog. It arrives after the shared parts and leaves first. |
MorphDialogDescription / MorphDialogClose
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | The dialog's description, and a close control of your own (a Back or Cancel button). |
06Notes
Behavior
- The surface, image, title and subtitle are shared between card and dialog, so opening carries them across and closing flies them back; the card lifts above its neighbors until it lands.
- While the dialog is open the card keeps an invisible copy of itself, so the grid never reflows.
- Content that only the dialog has is held back until the surface has nearly arrived, so nothing shows outside it mid-flight.
- With container set it opens within that element and uses focus trapping without locking page scroll, for previews, panels and embedded galleries.
Motion
- Opens on the soft spring (260/28) and folds back on the snappy one (520/38); radii go 12 → 16px on the surface and 8 → 10px on the image, corrected so they never stretch.
- The dialog-only content fades up 8px with a 2px blur after 160ms over 220ms, and leaves in 80ms. The backdrop fades in 200ms and out in 180ms.
- Escape closes on the same frame without the flight. Reduced motion drops the shared-layout flight entirely and keeps 120ms fades.
Accessibility
- Built on the Base UI dialog: role dialog, labeled by the title and described by the description, focus trapped while open.
- Focus moves to the dialog itself on open, so the title is read first and Tab reaches the actions; it returns to the card on close.
- The close button is always present over the image, with a 44px hit area on touch; Escape and the backdrop also close it.