A confirmation dialog for destructive or irreversible actions that scales in over a blurred backdrop with Cancel focused by default. Clicking the backdrop does not dismiss it; the panel gives a gentle pulse instead, so a decision is always explicit.
Preview
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add alert-dialogWrites components/ui/alert-dialog.tsx and components/styles/alert-dialog.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { AlertDialog } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <AlertDialog />6 );7}Props
Accessibility
Implements the WAI-ARIA alertdialog pattern: role="alertdialog" with aria-modal, aria-labelledby (title) and aria-describedby (description) so the consequence is announced. Focus moves to the Cancel button (the least destructive action) on open, Tab/Shift+Tab are trapped inside, Escape cancels, and focus returns to the trigger on close. Backdrop clicks never confirm or dismiss; they refocus Cancel. Body scroll is locked while open. The warning icon is decorative. Under prefers-reduced-motion the panel only fades and the pulse is disabled.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/alert-dialog.module.css";7 8export interface AlertDialogProps {9 /** Controlled open state */10 open?: boolean;11 /** Initial open state when uncontrolled */12 defaultOpen?: boolean;13 /** Called whenever the dialog requests to open or close */14 onOpenChange?: (open: boolean) => void;15 /** Called when the confirm action is pressed (the dialog then closes) */16 onConfirm?: () => void;17 /** Called when the dialog is dismissed with Cancel or Escape */18 onCancel?: () => void;19 /** Label of the trigger button */20 triggerLabel?: string;21 /** Dialog title (accessible name) */22 title?: string;23 /** Explanation of the consequences (accessible description) */24 description?: string;25 /** Label of the confirm action */26 confirmLabel?: string;27 /** Label of the cancel action (focused by default) */28 cancelLabel?: string;29 /** Destructive styling (red) or neutral styling (accent) for the confirm action */30 variant?: "destructive" | "default";31 /** Accent color used by the default variant */32 accentColor?: string;33 /** Color used by the destructive variant */34 destructiveColor?: string;35 /** Show the warning icon next to the title */36 showIcon?: boolean;37 /** Backdrop blur in pixels */38 backdropBlur?: number;39 /** Optional className merged onto the root */40 className?: string;41 /** Optional inline styles merged onto the root */42 style?: React.CSSProperties;43}44 45const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];46const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';47const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;48 49function readThemeVars(el: Element | null): Record<string, string> {50 const out: Record<string, string> = {};51 if (!el) return out;52 const cs = getComputedStyle(el);53 for (const t of THEME_TOKENS) {54 const v = cs.getPropertyValue(t).trim();55 if (v) out[t] = v;56 }57 return out;58}59 60export function AlertDialog({61 open: openProp,62 defaultOpen = false,63 onOpenChange,64 onConfirm,65 onCancel,66 triggerLabel = "Delete account",67 title = "Are you absolutely sure?",68 description = "This action cannot be undone. This will permanently delete your account and remove your data from our servers.",69 confirmLabel = "Yes, delete account",70 cancelLabel = "Cancel",71 variant = "destructive",72 accentColor = "#6366f1",73 destructiveColor = "#ef4444",74 showIcon = true,75 backdropBlur = 4,76 className = "",77 style,78}: AlertDialogProps) {79 const reduce = useReducedMotion();80 const [inner, setInner] = React.useState(defaultOpen);81 const open = openProp !== undefined ? openProp : inner;82 const setOpen = React.useCallback(83 (v: boolean) => {84 if (openProp === undefined) setInner(v);85 onOpenChange?.(v);86 },87 [openProp, onOpenChange],88 );89 const cancelRef = React.useRef(() => {});90 cancelRef.current = () => {91 onCancel?.();92 setOpen(false);93 };94 const [mounted, setMounted] = React.useState(false);95 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});96 const [nudge, setNudge] = React.useState(false);97 const rootRef = React.useRef<HTMLDivElement>(null);98 const triggerRef = React.useRef<HTMLButtonElement>(null);99 const panelRef = React.useRef<HTMLDivElement>(null);100 const cancelBtnRef = React.useRef<HTMLButtonElement>(null);101 const wasOpen = React.useRef(false);102 const id = React.useId();103 const tone = variant === "destructive" ? destructiveColor : accentColor;104 105 React.useEffect(() => setMounted(true), []);106 useIsoLayoutEffect(() => {107 if (open) setThemeVars(readThemeVars(rootRef.current));108 }, [open]);109 110 React.useEffect(() => {111 if (!nudge) return;112 const t = window.setTimeout(() => setNudge(false), 320);113 return () => window.clearTimeout(t);114 }, [nudge]);115 116 React.useEffect(() => {117 if (!open) {118 if (wasOpen.current) triggerRef.current?.focus();119 wasOpen.current = false;120 return;121 }122 wasOpen.current = true;123 const body = document.body;124 const prev = [body.style.overflow, body.style.paddingRight];125 const gap = window.innerWidth - document.documentElement.clientWidth;126 body.style.overflow = "hidden";127 if (gap > 0) body.style.paddingRight = `${gap}px`;128 const onKey = (e: KeyboardEvent) => {129 if (e.key !== "Escape") return;130 e.preventDefault();131 cancelRef.current();132 };133 document.addEventListener("keydown", onKey);134 const raf = requestAnimationFrame(() => (cancelBtnRef.current ?? panelRef.current)?.focus());135 return () => {136 cancelAnimationFrame(raf);137 document.removeEventListener("keydown", onKey);138 body.style.overflow = prev[0];139 body.style.paddingRight = prev[1];140 };141 }, [open]);142 143 const trapFocus = (e: React.KeyboardEvent<HTMLDivElement>) => {144 if (e.key !== "Tab" || !panelRef.current) return;145 const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));146 if (!nodes.length) return e.preventDefault();147 const [first, last] = [nodes[0], nodes[nodes.length - 1]];148 if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {149 e.preventDefault();150 last.focus();151 } else if (!e.shiftKey && document.activeElement === last) {152 e.preventDefault();153 first.focus();154 }155 };156 157 const ease = [0.16, 1, 0.3, 1] as const;158 159 return (160 <div ref={rootRef} className={`${styles.root} ${className}`.trim()} style={{ "--mf-ad-tone": tone, ...style } as React.CSSProperties}>161 <button ref={triggerRef} type="button" aria-haspopup="dialog" aria-expanded={open} data-variant={variant} className={styles.trigger} onClick={() => setOpen(true)}>162 {triggerLabel}163 </button>164 {mounted &&165 createPortal(166 <AnimatePresence>167 {open && (168 <div className={styles.layer} style={{ ...themeVars, "--mf-ad-tone": tone, "--mf-ad-blur": `${Math.max(0, backdropBlur)}px` } as React.CSSProperties}>169 <motion.div aria-hidden="true" className={styles.backdrop} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduce ? 0.1 : 0.2 }} />170 <div171 className={styles.center}172 onPointerDown={(e) => {173 if (e.target !== e.currentTarget) return;174 e.preventDefault();175 if (!reduce) setNudge(true);176 cancelBtnRef.current?.focus();177 }}178 >179 <motion.div180 ref={panelRef}181 role="alertdialog"182 aria-modal="true"183 aria-labelledby={`${id}-title`}184 aria-describedby={`${id}-desc`}185 tabIndex={-1}186 data-nudge={nudge ? "" : undefined}187 className={styles.panel}188 onKeyDown={trapFocus}189 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 10 }}190 animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}191 exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 4 }}192 transition={reduce ? { duration: 0.1 } : { duration: 0.22, ease }}193 >194 <div className={styles.content}>195 {showIcon && (196 <span aria-hidden="true" className={styles.icon}>197 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">198 <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0zM12 9v4M12 17h.01" />199 </svg>200 </span>201 )}202 <div className={styles.text}>203 <h2 id={`${id}-title`} className={styles.title}>204 {title}205 </h2>206 <p id={`${id}-desc`} className={styles.description}>207 {description}208 </p>209 </div>210 </div>211 <div className={styles.footer}>212 <button ref={cancelBtnRef} type="button" className={styles.cancel} onClick={() => cancelRef.current()}>213 {cancelLabel}214 </button>215 <button216 type="button"217 className={styles.confirm}218 onClick={() => {219 onConfirm?.();220 setOpen(false);221 }}222 >223 {confirmLabel}224 </button>225 </div>226 </motion.div>227 </div>228 </div>229 )}230 </AnimatePresence>,231 document.body,232 )}233 </div>234 );235}