UIOverlays and Menusmotion/react

AlertDialog

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 props
4

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add alert-dialog

Writes 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.

example.tsx
1import { AlertDialog } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <AlertDialog />6  );7}

Props

PropTypeDefault
open

Controlled open state.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called whenever the dialog requests to open or close.

(open: boolean) => voidundefined
onConfirm

Called when the confirm action is pressed; the dialog then closes.

() => voidundefined
onCancel

Called when the dialog is dismissed with Cancel or Escape.

() => voidundefined
triggerLabel

Label of the trigger button.

string'Delete account'
title

Dialog title, used as the accessible name.

string'Are you absolutely sure?'
description

Explanation of the consequences, used as the accessible description.

string'This action cannot be undone. This will permanently delete your account and remove your data from our servers.'
confirmLabel

Label of the confirm action.

string'Yes, delete account'
cancelLabel

Label of the cancel action, which receives focus on open.

string'Cancel'
variant

Red destructive styling or neutral accent styling for the trigger, icon and confirm action.

'destructive' | 'default''destructive'
accentColor

Tone used by the default variant.

string'#6366f1'
destructiveColor

Tone used by the destructive variant.

string'#ef4444'
showIcon

Show the warning icon beside the title.

booleantrue
backdropBlur

Backdrop blur radius in pixels.

number4
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/alert-dialog.tsx
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}