Modal Scale In
A dialog grows the last few percent into place behind a darkening scrim, and leaves the same way.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Modal Scale In
*
* A confirmation dialog that grows the last few percent into place behind
* a darkening scrim, and leaves the same way it arrived. Escape or the
* scrim dismisses it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The page behind is mixed from the inherited text color and the dialog
* follows the host app's color scheme, so both land correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `title`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ModalScaleInProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Dialog heading, also its accessible name. */
title?: string;
/** Notified whenever the dialog opens or closes. */
onOpenChange?: (open: boolean) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** How far below full size the dialog starts, as a fraction. */
scaleFrom: number;
scrimFade: number;
exitDuration: number;
};
// Quality rule: a dialog carries text, and text that overshoots its final
// size is the cheapest-looking thing in interface motion. Every spring
// here sits well above a 0.8 damping ratio so the surface arrives at 1.0
// and stops — no pass beyond it — and the travel is only a few percent,
// small enough that glyph rescaling is imperceptible. Variants change the
// distance and the pace, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost pure fade. For destructive confirmations that should not feel
// like an event.
subtle: {
spring: { type: "spring", stiffness: 800, damping: 55 },
scaleFrom: 0.99,
scrimFade: 0.09,
exitDuration: 0.09,
},
// A short, clean rise into place. The all-purpose setting.
default: {
spring: { type: "spring", stiffness: 460, damping: 40 },
scaleFrom: 0.96,
scrimFade: 0.2,
exitDuration: 0.15,
},
// Slightly more travel so the dialog reads as arriving from in front of
// the page rather than simply appearing on it.
playful: {
spring: { type: "spring", stiffness: 360, damping: 33 },
scaleFrom: 0.88,
scrimFade: 0.26,
exitDuration: 0.21,
},
};
const ACCENT = "#7C7CF0";
const DANGER = "#E05260";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ModalScaleIn({
variant = "default",
title = "Archive this report?",
onOpenChange,
}: ModalScaleInProps) {
const [open, setOpen] = useState(false);
const [archived, setArchived] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const setDialog = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
onOpenChange?.(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
// Reduced motion: the dialog still arrives over a dimmed page and still
// owns the screen, it just stops changing size to get there.
const dialogMotion = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0.12 } },
transition: { duration: 0.14, ease: "easeOut" as const },
}
: {
initial: { opacity: 0, scale: cfg.scaleFrom, y: 6 },
animate: { opacity: 1, scale: 1, y: 0 },
// Leaving is not worth watching: a hair of shrink and a quick
// ease-out beats replaying the entrance backwards.
exit: {
opacity: 0,
scale: cfg.scaleFrom + (1 - cfg.scaleFrom) * 0.6,
transition: { duration: cfg.exitDuration, ease: "easeIn" as const },
},
transition: cfg.spring,
};
return (
<div
style={{
position: "relative",
width: 318,
height: 344,
display: "flex",
flexDirection: "column",
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 16px 40px rgba(0,0,0,0.18)",
// The scrim and dialog are positioned against this box rather than
// the viewport, so the pattern drops into a preview or an embedded
// card. For an app-level dialog, swap `absolute` for `fixed` on the
// scrim and the centering wrapper below.
overflow: "hidden",
}}
>
<div style={{ padding: "18px 18px 0" }}>
<div style={{ fontSize: 12, opacity: 0.5 }}>Documents · Reports</div>
<div style={{ fontSize: 16, fontWeight: 650, marginTop: 3 }}>
Q3 revenue summary
</div>
</div>
<div style={{ flex: 1, padding: "14px 18px" }}>
{[
["Owner", "Priya Raman"],
["Updated", "4 minutes ago"],
["Shared with", "9 people"],
["Status", archived ? "Archived" : "Active"],
].map(([field, value]) => (
<div
key={field}
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
padding: "8px 0",
borderBottom: `1px solid ${tone(10)}`,
fontSize: 12.5,
}}
>
<span style={{ opacity: 0.55 }}>{field}</span>
<span style={{ fontWeight: 600 }}>{value}</span>
</div>
))}
</div>
<div style={{ padding: "0 18px 18px" }}>
<button
type="button"
onClick={() => setDialog(true)}
disabled={archived}
style={{
width: "100%",
padding: "11px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 11,
border: `1px solid ${tone(14)}`,
background: tone(8),
color: "inherit",
opacity: archived ? 0.45 : 1,
cursor: archived ? "default" : "pointer",
}}
>
{archived ? "Report archived" : "Archive report"}
</button>
</div>
{/* The scrim and the dialog get their own AnimatePresence: the dialog
lives inside a centering wrapper, and AnimatePresence only tracks
its own direct children. */}
<AnimatePresence>
{open && (
<motion.div
key="scrim"
aria-hidden
onClick={() => setDialog(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: cfg.exitDuration } }}
transition={{ duration: cfg.scrimFade, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
// A scrim darkens in both themes — light or dark, the page
// behind a dialog recedes — so this one stays literal.
background: "rgba(0,0,0,0.46)",
cursor: "pointer",
}}
/>
)}
</AnimatePresence>
{/* Centering is layout, not motion: the wrapper never animates, so
the dialog is free to own `y` outright instead of fighting a
`translateY(-50%)` for the same transform slot. It is inert until
the dialog is in it. The extra bottom padding lifts the dialog
off the true centre — dead centre reads as slightly low. */}
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
padding: "20px 22px 46px",
pointerEvents: "none",
}}
>
<AnimatePresence>
{open && (
<motion.div
key="dialog"
role="dialog"
aria-modal="true"
aria-label={title}
{...dialogMotion}
style={{
width: "100%",
pointerEvents: "auto",
padding: "18px 18px 16px",
borderRadius: 16,
// The one surface here that cannot be translucent: it sits
// on top of the scrim, and a see-through dialog would read
// as more scrim. `Canvas`/`CanvasText` are the CSS system
// colors for page background and page text, so the dialog
// lands light in a light app and dark in a dark one.
// Everything inside then mixes from `currentColor`.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 20px 48px rgba(0,0,0,0.34)",
}}
>
<div
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 32,
height: 32,
borderRadius: 10,
background: tone(10),
color: ACCENT,
}}
>
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2.8" y="4" width="14.4" height="3.6" rx="1.2" />
<path d="M4.4 7.6v7a1.6 1.6 0 0 0 1.6 1.6h8a1.6 1.6 0 0 0 1.6-1.6v-7" />
<path d="M8.2 10.8h3.6" />
</svg>
</div>
<div style={{ fontSize: 15, fontWeight: 650, marginTop: 11 }}>
{title}
</div>
<p
style={{
margin: "5px 0 0",
fontSize: 12.5,
lineHeight: 1.55,
opacity: 0.65,
}}
>
It moves out of Reports and stops appearing in search. You
can restore it from the archive at any time.
</p>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button
type="button"
onClick={() => setDialog(false)}
style={{
flex: 1,
padding: "10px 12px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 10,
border: `1px solid ${tone(16)}`,
background: "transparent",
color: "inherit",
cursor: "pointer",
}}
>
Cancel
</button>
<button
type="button"
onClick={() => {
setArchived(true);
setDialog(false);
}}
style={{
flex: 1,
padding: "10px 12px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 10,
border: 0,
background: DANGER,
color: "#ffffff",
cursor: "pointer",
}}
>
Archive
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}About this pattern
The confirmation step, given just enough presence to interrupt. The dialog starts a few percent under full size and settles at exactly 1.0 while the scrim darkens beneath it — that tiny travel is what makes it read as arriving in front of the page rather than being pasted onto it. Restraint is the whole craft here: a dialog is mostly text, and text that overshoots its final size is the cheapest-looking thing in interface motion, so the spring is damped well past the wobble threshold and the scale delta stays small enough that glyph rescaling is imperceptible. Dismissal is deliberately not the entrance reversed — a hair of shrink and a quick ease-out, because leaving is not worth watching.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Modal sheet
The dialog scales up from just under full size as the page behind it dims.
Related patterns
- Bottom Sheet PresentA sheet rises from the bottom edge over a dimming backdrop and settles once.
- Gallery Lightbox OpenThe pressed thumbnail flies out of the grid into the large view while the backdrop dims behind it.
- Drawer Slide InA side drawer travels in over the page, and the page eases back a little to hand it the foreground.