Feedback & Data
DialogMorph
The button itself grows into the dialog and shrinks back into place on close. Focus stays inside while open, Esc or a click outside closes, and focus returns to the button.
Download
- React
- Tailwind CSS v4
- motion
components/ui/dialog-morph.tsx
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type DialogMorphProps = {
/** Button label. */
trigger: ReactNode;
/** Dialog heading. Also its name for screen readers. */
title: string;
/** Small text under the heading. */
description?: string;
/** Dialog body. Pass a function to get a close() for your own buttons. */
children?: ReactNode | ((close: () => void) => ReactNode);
/** Keep the dialog inside the nearest positioned parent instead of the screen. */
contained?: boolean;
/** Replaces the default button look. */
triggerClassName?: string;
/** Replaces the default dialog look. */
className?: string;
};
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const MORPH = { type: "spring" as const, stiffness: 380, damping: 34 };
/* DialogMorph: the button itself grows into the dialog and shrinks back into place on close. Focus stays trapped inside while open and returns to the button after. */
export function DialogMorph({
trigger,
title,
description,
children,
contained = false,
triggerClassName = "inline-flex items-center gap-2 bg-[#a78bfa] px-5 py-2.5 text-sm font-medium text-[#1a1530] outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b0a14]",
className = "border border-white/10 bg-[#141026] p-6 text-white shadow-2xl shadow-black/60",
}: DialogMorphProps) {
const reduce = useReducedMotion();
const id = useId();
const button = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const wasOpen = useRef(false);
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
const shared = reduce ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : { layoutId: `morph-${id}` };
// Focus moves in on open and back to the button on close.
useEffect(() => {
if (open) (panel.current?.querySelector<HTMLElement>(FOCUSABLE) ?? panel.current)?.focus({ preventScroll: true });
else if (wasOpen.current) button.current?.focus({ preventScroll: true });
wasOpen.current = open;
}, [open]);
// Lock page scroll while open.
useEffect(() => {
if (!open || contained) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open, contained]);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") return close();
if (e.key !== "Tab" || !panel.current) return;
const items = [...panel.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
const edge = e.shiftKey ? items[0] : items[items.length - 1];
if (document.activeElement === edge || document.activeElement === panel.current) {
e.preventDefault();
(e.shiftKey ? items[items.length - 1] : items[0])?.focus();
}
};
return (
<>
{open ? (
<span aria-hidden style={{ borderRadius: 14 }} className={`invisible ${triggerClassName}`}>
{trigger}
</span>
) : (
<motion.button
ref={button}
type="button"
aria-haspopup="dialog"
layoutId={reduce ? undefined : `morph-${id}`}
transition={MORPH}
style={{ borderRadius: 14 }}
onClick={() => setOpen(true)}
className={triggerClassName}
>
{trigger}
</motion.button>
)}
<AnimatePresence>
{open && (
<div key="layer" className={`${contained ? "absolute" : "fixed"} inset-0 z-50 grid place-items-center p-4`}>
<motion.div
aria-hidden
onClick={close}
className="absolute inset-0 bg-black/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
<motion.div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={description ? `${id}-text` : undefined}
tabIndex={-1}
onKeyDown={onKeyDown}
{...shared}
transition={reduce ? { duration: 0.15 } : MORPH}
style={{ borderRadius: 20 }}
className={`relative max-h-full w-full max-w-md overflow-y-auto outline-none ${className}`}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: reduce ? 0 : 0.16 } }}
exit={{ opacity: 0, transition: { duration: 0.08 } }}
>
<h2 id={`${id}-title`} className="pr-8 text-lg font-semibold">
{title}
</h2>
{description && (
<p id={`${id}-text`} className="mt-1.5 text-sm text-white/60">
{description}
</p>
)}
{children && <div className="mt-4">{typeof children === "function" ? children(close) : children}</div>}
<button
type="button"
aria-label="Close"
onClick={close}
className="absolute top-3.5 right-3.5 grid h-8 w-8 place-items-center rounded-lg text-white/50 outline-none hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70"
>
<svg aria-hidden viewBox="0 0 12 12" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M2 2l8 8M10 2l-8 8" />
</svg>
</button>
</motion.div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/dialog-morph.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/dialog-morph-demo.tsx
"use client"; import { DialogMorph } from "@/components/ui/dialog-morph"; export default function DialogMorphExample() { // `contained` keeps the dialog inside this box. On a real page, leave it out. return ( <div className="relative grid h-full place-items-center overflow-hidden bg-[#0b0a14] p-6"> <DialogMorph trigger="Delete project" title="Delete this project?" description="Its files go too. You can't undo this." contained> {(close) => ( <div className="flex justify-end gap-2"> <button type="button" onClick={close} className="rounded-lg px-3.5 py-2 text-sm text-white/70 outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70" > Cancel </button> <button type="button" onClick={close} className="rounded-lg bg-[#f87171] px-3.5 py-2 text-sm font-medium text-[#2a0f12] outline-none focus-visible:ring-2 focus-visible:ring-white/70" > Delete </button> </div> )} </DialogMorph> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| trigger | ReactNode | โ | Button label. |
| title | string | โ | Dialog heading. Also its name for screen readers. |
| description | string | โ | Small text under the heading. |
| children | ReactNode | (close) => ReactNode | โ | Dialog body. Use the function form to close from your own buttons. |
| contained | boolean | false | Stay inside the nearest positioned parent instead of the screen. |
| triggerClassName | string | violet button | Replaces the button look. |
| className | string | dark panel | Replaces the dialog look. |