# Dialog Scale Spring — React Dialog Component

> A modal that springs up from 86% scale over a blurred scrim, traps focus, closes on Escape or outside click and hands focus back to its trigger.

- Category: [Dialogs & Drawers](https://pixelpapa.com/components/dialogs.md)
- Tags: dialog, modal, spring, focus-trap, accessible
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/dialogs/dialog-scale-spring

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/dialog-scale-spring.json"
```

The file is written to `components/pixelpapa/dialog-scale-spring.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Rocket, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState, type ReactNode, type RefObject } from "react";
import { cn } from "@/lib/utils";

const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

/** Moves focus into `ref` while open, keeps Tab inside it, closes on Escape and restores focus afterwards. */
function useModalFocus(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    const node = ref.current;
    if (!open || !node) return;
    const opener = document.activeElement as HTMLElement | null;
    (node.querySelector<HTMLElement>("[data-autofocus]") ?? node).focus({ preventScroll: true });
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") return close.current();
      if (e.key !== "Tab") return;
      const items = [...node.querySelectorAll<HTMLElement>(FOCUSABLE)];
      const first = items[0];
      const last = items[items.length - 1];
      const at = document.activeElement;
      if (!first || (e.shiftKey ? at === first || at === node : at === last)) {
        e.preventDefault();
        (e.shiftKey ? last : first)?.focus();
      }
    };
    node.addEventListener("keydown", onKeyDown);
    return () => {
      node.removeEventListener("keydown", onKeyDown);
      opener?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

type SpringDialogProps = {
  open: boolean;
  onClose: () => void;
  title: string;
  description?: string;
  children?: ReactNode;
  /** Usually the action buttons. */
  footer?: ReactNode;
  /** Covers the nearest positioned ancestor. Pass "fixed" to cover the viewport instead. */
  className?: string;
};

export function SpringDialog({ open, onClose, title, description, children, footer, className }: SpringDialogProps) {
  const id = useId();
  const reduce = useReducedMotion();
  const panel = useRef<HTMLDivElement>(null);
  useModalFocus(panel, open, onClose);

  return (
    <AnimatePresence>
      {open && (
        <motion.div key="dialog" className={cn("absolute inset-0 z-50 grid place-items-center p-4", className)}>
          <motion.div
            aria-hidden
            onClick={onClose}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
            className="absolute inset-0 bg-black/40 backdrop-blur-[3px]"
          />
          <motion.div
            ref={panel}
            role="dialog"
            aria-modal="true"
            aria-labelledby={`${id}-title`}
            aria-describedby={description ? `${id}-desc` : undefined}
            tabIndex={-1}
            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.86, y: 18 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 8, transition: { duration: 0.15 } }}
            transition={{ type: "spring", stiffness: 420, damping: 26, mass: 0.9 }}
            className="relative w-full max-w-sm rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-2xl shadow-black/20 outline-none"
          >
            <button
              aria-label="Close"
              onClick={onClose}
              className="absolute top-3.5 right-3.5 flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
            >
              <X className="size-4" />
            </button>
            <h2 id={`${id}-title`} className="pr-8 text-lg font-semibold tracking-tight">
              {title}
            </h2>
            {description && (
              <p id={`${id}-desc`} className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
                {description}
              </p>
            )}
            {children}
            {footer && <div className="mt-6 flex justify-end gap-2">{footer}</div>}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

const changes = [
  ["Checkout", "New one-page flow", "bg-emerald-500"],
  ["Pricing", "Annual plans −20%", "bg-amber-500"],
  ["Emails", "Receipt redesign", "bg-primary"],
];

export default function DialogScaleSpringDemo() {
  const [open, setOpen] = useState(true);
  const [published, setPublished] = useState(false);

  return (
    <div className="relative isolate flex h-[420px] w-full max-w-[680px] items-center justify-center overflow-hidden rounded-3xl border border-border bg-muted/40 [background-image:radial-gradient(var(--border)_1px,transparent_1px)] [background-size:18px_18px]">
      <div className="text-center">
        <p className="text-sm text-muted-foreground">{published ? "v2.41.0 is live in production." : "3 changes are waiting on staging."}</p>
        <button
          onClick={() => setOpen(true)}
          className="mt-4 inline-flex h-10 items-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-transform hover:-translate-y-0.5 active:scale-95"
        >
          <Rocket className="size-4" /> Publish changes
        </button>
      </div>

      <SpringDialog
        open={open}
        onClose={() => setOpen(false)}
        title="Publish to production?"
        description="These changes go live for all 4,218 teams as soon as you confirm."
        footer={
          <>
            <button
              onClick={() => setOpen(false)}
              className="h-9 rounded-lg border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted"
            >
              Not yet
            </button>
            <button
              onClick={() => {
                setPublished(true);
                setOpen(false);
              }}
              className="h-9 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm shadow-primary/30 transition-transform active:scale-95"
            >
              Publish now
            </button>
          </>
        }
      >
        <ul className="mt-4 divide-y divide-border rounded-xl border border-border">
          {changes.map(([area, what, dot]) => (
            <li key={area} className="flex items-center gap-3 px-3.5 py-2.5 text-sm">
              <span className={cn("size-2 rounded-full", dot)} />
              <span className="font-medium">{area}</span>
              <span className="ml-auto text-muted-foreground">{what}</span>
            </li>
          ))}
        </ul>
      </SpringDialog>
    </div>
  );
}
```

## AI prompt

```text
Add the "Dialog Scale Spring" component from PixelPapa UI to this project.

What it is: A modal that springs up from 86% scale over a blurred scrim, traps focus, closes on Escape or outside click and hands focus back to its trigger.
Category: Dialogs & Drawers. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/dialog-scale-spring.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/dialog-scale-spring.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/dialog-scale-spring" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More dialogs & drawers

- [Dialog Command Style Search](https://pixelpapa.com/components/dialogs/dialog-command-style-search.md) (Pro): A search dialog in the style of a command palette: scope chips, matches highlighted as you type, and a selection bar that springs between results with the arrow keys.
- [Dialog Confirm Destructive](https://pixelpapa.com/components/dialogs/dialog-confirm-destructive.md) (Pro): A type-to-confirm dialog: the delete button fills up as each character of the resource name matches, unlocks at the last one and shakes on a wrong try.
- [Dialog Morph From Button](https://pixelpapa.com/components/dialogs/dialog-morph-from-button.md): A button that grows into the dialog itself through a shared layout, with its label travelling to become the title, then shrinks back on close.
- [Dialog Multi Step Wizard](https://pixelpapa.com/components/dialogs/dialog-multi-step-wizard.md) (Pro): A wizard dialog whose steps slide in the direction of travel while the card springs to each step's height and a segmented progress bar fills.
- [Drawer Bottom Sheet Drag](https://pixelpapa.com/components/dialogs/drawer-bottom-sheet-drag.md): A bottom sheet you can drag down to dismiss: a slow pull past the threshold or a quick flick closes it, and the scrim fades with the drag distance.
- [Drawer Side Panel](https://pixelpapa.com/components/dialogs/drawer-side-panel.md) (Pro): A floating settings panel that slides in from the right with staggered rows while the page behind recedes and dims.
