Morphing dialog
A card that is also a dialog. Press it and the same surface grows to the middle of the screen while the backdrop fades in; its extra content arrives a beat later.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// MorphDialog: a card that is also a dialog. Press it and the same surface grows to the middle of the screen while
// the backdrop fades in; its extra content arrives a beat later. Close and it shrinks back into its slot.
// Picture paths default to Teseria's demo art (/art/*.svg); pass your own src.
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent as RKE, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
export type MorphDialogCue = "unfold" | "fold";
export type MorphDialogProps = {
/** Shown in the card and, larger, in the dialog. */
title?: ReactNode;
/** Line under the title, in both states. */
summary?: ReactNode;
/** Only shown once the card has grown into the dialog. */
children?: ReactNode;
/** Picture URL. The default only exists in Teseria. */
src?: string;
/** Picture alt text. */
alt?: string;
/** Card width in px. */
width?: number;
/** Dialog width in px. */
dialogWidth?: number;
/** Card corner radius in px; the dialog is 4px rounder. */
corner?: number;
/** Controlled open state. */
open?: boolean;
/** Initial open state when uncontrolled. */
defaultOpen?: boolean;
/** Called when the dialog opens or closes. */
onOpenChange?: (open: boolean) => void;
/** Label of the close button. */
closeLabel?: string;
/** Sound hook: "unfold" on open, "fold" on close. */
onSound?: (cue: MorphDialogCue) => void;
className?: string;
style?: CSSProperties;
};
const MORPH = { type: "spring", stiffness: 340, damping: 30, mass: 0.8 } as const;
export function MorphDialog({
title = "Lisbon offsite",
summary = "4 nights · 12 people",
children = <p>Flights land Tuesday afternoon. Rooftop at the studio from Wednesday; Thursday is free. Bring a jacket for the evenings.</p>,
src = "/art/tide.svg",
alt = "Tide",
width = 240,
dialogWidth = 420,
corner = 20,
open: controlled,
defaultOpen = false,
onOpenChange,
closeLabel = "Close",
onSound,
className,
style,
}: MorphDialogProps) {
const id = useId();
const [inner, setInner] = useState(defaultOpen);
const open = controlled ?? inner;
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const card = useRef<HTMLButtonElement>(null);
const set = (v: boolean) => {
onSound?.(v ? "unfold" : "fold");
setInner(v);
onOpenChange?.(v);
if (!v) setTimeout(() => card.current?.focus({ preventScroll: true }), 0);
};
const closeRef = useRef(() => {});
closeRef.current = () => set(false);
useEffect(() => {
if (!open) return;
const k = (e: KeyboardEvent) => e.key === "Escape" && closeRef.current();
document.addEventListener("keydown", k);
return () => document.removeEventListener("keydown", k);
}, [open]);
// Tab wraps inside the open dialog
const trap = (e: RKE<HTMLElement>) => {
if (e.key !== "Tab") return;
const stops = [...e.currentTarget.querySelectorAll<HTMLElement>("a[href], button:not(:disabled), input, textarea, select, [tabindex]:not([tabindex='-1'])")];
const edge = stops[e.shiftKey ? 0 : stops.length - 1];
if (document.activeElement !== edge) return;
e.preventDefault();
stops[e.shiftKey ? stops.length - 1 : 0].focus();
};
return (
<>
<motion.button ref={card} type="button" className={`mdl-card mdl-root${className ? ` ${className}` : ""}`} layoutId={`${id}-s`} style={{ ...style, width, borderRadius: corner, visibility: open ? "hidden" : "visible" }} onClick={() => set(true)} transition={MORPH} aria-haspopup="dialog" aria-expanded={open}>
<motion.img layoutId={`${id}-art`} className="mdl-art" src={src} alt={alt} draggable={false} style={{ borderRadius: corner - 6 }} transition={MORPH} />
<motion.span layoutId={`${id}-t`} className="mdl-title" transition={MORPH}>
{title}
</motion.span>
{summary && (
<motion.span layoutId={`${id}-sum`} className="mdl-sum" transition={MORPH}>
{summary}
</motion.span>
)}
</motion.button>
{mounted &&
createPortal(
<AnimatePresence>
{open && (
<motion.div key="bg" className="mdl-bg mdl-root" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.18 } }} transition={{ duration: 0.26 }} onClick={() => set(false)}>
<motion.div role="dialog" aria-modal aria-label={typeof title === "string" ? title : undefined} className="mdl-dialog" layoutId={`${id}-s`} style={{ width: dialogWidth, borderRadius: corner + 4 }} transition={MORPH} onClick={(e) => e.stopPropagation()} onKeyDown={trap}>
<motion.img layoutId={`${id}-art`} className="mdl-art mdl-art-big" src={src} alt={alt} draggable={false} style={{ borderRadius: corner - 2 }} transition={MORPH} />
<div className="mdl-body">
<motion.span layoutId={`${id}-t`} className="mdl-title mdl-title-big" transition={MORPH}>
{title}
</motion.span>
{summary && (
<motion.span layoutId={`${id}-sum`} className="mdl-sum" transition={MORPH}>
{summary}
</motion.span>
)}
<motion.div className="mdl-more" initial={{ opacity: 0, y: 8, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, filter: "blur(4px)", transition: { duration: 0.1 } }} transition={{ duration: 0.22, delay: 0.14 }}>
{children}
</motion.div>
</div>
<motion.button type="button" className="mdl-x" aria-label={closeLabel} onClick={() => set(false)} ref={(el) => { if (el) setTimeout(() => el.focus({ preventScroll: true }), 0); }} initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }} transition={{ type: "spring", stiffness: 500, damping: 18, mass: 0.7, delay: 0.1, opacity: { duration: 0.14, delay: 0.1 } }}>
<X size={15} strokeWidth={2.2} />
</motion.button>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}
/* Morphing dialog. Prefix mdl-. */
/* the card and the portalled backdrop both carry .mdl-root */
.mdl-root, .mdl-root *, .mdl-root *::before, .mdl-root *::after { box-sizing: border-box; }
.mdl-root { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.mdl-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; }
.mdl-root:focus-visible, .mdl-root :focus-visible { outline: 2px solid var(--ts-focus); outline-offset: 2px; }
.mdl-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 8px 16px 16px;
border: 0;
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
text-align: left;
cursor: pointer;
overflow: hidden;
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), box-shadow 0.16s cubic-bezier(.22, 1, .36, 1);
}
.mdl-card:hover {
box-shadow: var(--ts-edge), 0 10px 30px -18px rgba(0, 0, 0, 0.35);
}
.mdl-card:active {
scale: 0.97;
}
.mdl-art {
display: block;
width: calc(100% + 16px);
height: 110px;
margin: 0 -8px 6px;
object-fit: cover;
}
.mdl-art-big {
height: 120px;
margin: 0 0 0;
width: 100%;
}
.mdl-title {
display: block;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
}
.mdl-title-big {
font-size: 20px;
letter-spacing: -0.02em;
}
.mdl-sum {
display: block;
font-size: 12.5px;
/* a colour, not opacity: the shared-layout morph animates opacity to 1 and would flash it bright */
color: var(--ts-fg-muted);
}
.mdl-bg {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.32);
backdrop-filter: blur(6px);
}
.mdl-dialog {
position: relative;
max-width: calc(100vw - 32px);
padding: 8px;
background: var(--ts-sunken);
color: var(--ts-fg);
box-shadow: var(--ts-edge), 0 30px 60px -30px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.mdl-body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 10px 10px;
}
.mdl-more {
margin-top: 8px;
font-size: 13.5px;
line-height: 1.5;
color: var(--ts-fg-secondary);
}
.mdl-more p {
margin: 0 0 8px;
}
.mdl-x {
position: absolute;
top: 16px;
right: 16px;
width: 30px;
height: 30px;
border-radius: 999px;
background: var(--ts-sunken);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
display: grid;
place-items: center;
cursor: pointer;
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1) !important;
}
.mdl-x:active {
scale: 0.92;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { MorphDialog } from "./MorphDialog";
import "./MorphDialog.css";
export default function Demo() {
return <MorphDialog />;
}