All patterns

Modal Scale In

A dialog grows the last few percent into place behind a darkening scrim, and leaves the same way.

navigationpremiumelegantinteraction · finite · starter · ~0.3s
Interactive · click to play
Variant

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.

355 lines · react + motion only
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.

Destructive confirmationSettings dialogInvite or share promptForm in an overlay

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Files
    Recent
    Shared
    Starred
    Trash
    FilesNew
    Brand refresh12 files
    Contracts6 files
    Photography48 files
    Q3 planning9 files
    Reports21 files
    Shared4 files
    Delete this project?Its 21 files and every share link stop working. This cannot be undone.
    CancelDelete
    Modal sheet

    The dialog scales up from just under full size as the page behind it dims.

Related patterns