A modal dialog that fades in a blurred backdrop and scales the panel up with a soft ease, with a title, description, body content and footer actions. Focus is trapped while open and restored to the trigger on close.
Preview
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add dialogWrites components/ui/dialog.tsx and components/styles/dialog.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Dialog } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Dialog />6 );7}Props
Accessibility
Implements the WAI-ARIA dialog (modal) pattern: role="dialog" with aria-modal, aria-labelledby (title) and aria-describedby (description). Focus moves into the panel on open, Tab/Shift+Tab cycle inside it, Escape and backdrop click close it, and focus returns to the trigger. Body scroll is locked while open. The panel is portaled to document.body only after mount and copies the --mf-* theme variables from its trigger. Under prefers-reduced-motion the panel only fades.
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/dialog.module.css";7 8export interface DialogProps {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 /** Label of the trigger button */16 triggerLabel?: string;17 /** Dialog title (used as the accessible name) */18 title?: string;19 /** Supporting description under the title (used as the accessible description) */20 description?: string;21 /** Body content; defaults to a small profile form */22 children?: React.ReactNode;23 /** Label of the primary action */24 confirmLabel?: string;25 /** Label of the secondary (cancel) action */26 cancelLabel?: string;27 /** Called when the primary action is pressed (the dialog then closes) */28 onConfirm?: () => void;29 /** Panel width preset */30 size?: "sm" | "md" | "lg";31 /** Accent color used for the primary action and focus highlights */32 accentColor?: string;33 /** Backdrop blur in pixels */34 backdropBlur?: number;35 /** Close when clicking the backdrop */36 closeOnOutsideClick?: boolean;37 /** Show the X close button in the corner */38 showCloseButton?: boolean;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 49/** Copies the --mf-* theme variables from an element so a portaled layer matches its surroundings. */50function readThemeVars(el: Element | null): Record<string, string> {51 const out: Record<string, string> = {};52 if (!el) return out;53 const cs = getComputedStyle(el);54 for (const token of THEME_TOKENS) {55 const v = cs.getPropertyValue(token).trim();56 if (v) out[token] = v;57 }58 return out;59}60 61function useControllable(value: boolean | undefined, defaultValue: boolean, onChange?: (v: boolean) => void): [boolean, (v: boolean) => void] {62 const [inner, setInner] = React.useState(defaultValue);63 const controlled = value !== undefined;64 const current = controlled ? value : inner;65 const set = React.useCallback(66 (v: boolean) => {67 if (!controlled) setInner(v);68 onChange?.(v);69 },70 [controlled, onChange],71 );72 return [current, set];73}74 75function DefaultBody({ fieldId }: { fieldId: string }) {76 return (77 <div className={styles.form}>78 <label className={styles.field} htmlFor={`${fieldId}-name`}>79 <span className={styles.label}>Name</span>80 <input id={`${fieldId}-name`} className={styles.input} defaultValue="Ada Lovelace" />81 </label>82 <label className={styles.field} htmlFor={`${fieldId}-user`}>83 <span className={styles.label}>Username</span>84 <input id={`${fieldId}-user`} className={styles.input} defaultValue="@ada" />85 </label>86 </div>87 );88}89 90export function Dialog({91 open: openProp,92 defaultOpen = false,93 onOpenChange,94 triggerLabel = "Edit profile",95 title = "Edit profile",96 description = "Make changes to your profile here. Click save when you're done.",97 children,98 confirmLabel = "Save changes",99 cancelLabel = "Cancel",100 onConfirm,101 size = "md",102 accentColor = "#6366f1",103 backdropBlur = 6,104 closeOnOutsideClick = true,105 showCloseButton = true,106 className = "",107 style,108}: DialogProps) {109 const reduce = useReducedMotion();110 const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);111 const [mounted, setMounted] = React.useState(false);112 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});113 const rootRef = React.useRef<HTMLDivElement>(null);114 const triggerRef = React.useRef<HTMLButtonElement>(null);115 const panelRef = React.useRef<HTMLDivElement>(null);116 const wasOpen = React.useRef(false);117 const baseId = React.useId();118 const titleId = `${baseId}-title`;119 const descId = `${baseId}-desc`;120 121 React.useEffect(() => setMounted(true), []);122 123 useIsoLayoutEffect(() => {124 if (open) setThemeVars(readThemeVars(rootRef.current));125 }, [open]);126 127 // Body scroll lock, Escape and focus restore.128 React.useEffect(() => {129 if (!open) {130 if (wasOpen.current) triggerRef.current?.focus();131 wasOpen.current = false;132 return;133 }134 wasOpen.current = true;135 const body = document.body;136 const prevOverflow = body.style.overflow;137 const prevPadding = body.style.paddingRight;138 const gap = window.innerWidth - document.documentElement.clientWidth;139 body.style.overflow = "hidden";140 if (gap > 0) body.style.paddingRight = `${gap}px`;141 const onKey = (e: KeyboardEvent) => {142 if (e.key === "Escape") {143 e.preventDefault();144 setOpen(false);145 }146 };147 document.addEventListener("keydown", onKey);148 const raf = requestAnimationFrame(() => {149 const panel = panelRef.current;150 if (!panel) return;151 const first = panel.querySelector<HTMLElement>("[data-autofocus]") ?? panel.querySelector<HTMLElement>(FOCUSABLE);152 (first ?? panel).focus();153 });154 return () => {155 cancelAnimationFrame(raf);156 document.removeEventListener("keydown", onKey);157 body.style.overflow = prevOverflow;158 body.style.paddingRight = prevPadding;159 };160 }, [open, setOpen]);161 162 const trapFocus = (e: React.KeyboardEvent<HTMLDivElement>) => {163 if (e.key !== "Tab" || !panelRef.current) return;164 const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));165 if (nodes.length === 0) {166 e.preventDefault();167 return;168 }169 const first = nodes[0];170 const last = nodes[nodes.length - 1];171 if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {172 e.preventDefault();173 last.focus();174 } else if (!e.shiftKey && document.activeElement === last) {175 e.preventDefault();176 first.focus();177 }178 };179 180 const layerStyle = {181 ...themeVars,182 "--mf-dlg-accent": accentColor,183 "--mf-dlg-blur": `${Math.max(0, backdropBlur)}px`,184 } as React.CSSProperties;185 186 const ease = [0.16, 1, 0.3, 1] as const;187 188 return (189 <div ref={rootRef} className={`${styles.root} ${className}`.trim()} style={{ "--mf-dlg-accent": accentColor, ...style } as React.CSSProperties}>190 <button ref={triggerRef} type="button" aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen(true)} className={styles.trigger}>191 {triggerLabel}192 </button>193 {mounted &&194 createPortal(195 <AnimatePresence>196 {open && (197 <div className={styles.layer} style={layerStyle}>198 <motion.div199 aria-hidden="true"200 className={styles.backdrop}201 initial={{ opacity: 0 }}202 animate={{ opacity: 1 }}203 exit={{ opacity: 0 }}204 transition={{ duration: reduce ? 0.1 : 0.2 }}205 />206 <div207 className={styles.center}208 onPointerDown={(e) => {209 if (closeOnOutsideClick && e.target === e.currentTarget) setOpen(false);210 }}211 >212 <motion.div213 ref={panelRef}214 role="dialog"215 aria-modal="true"216 aria-labelledby={titleId}217 aria-describedby={description ? descId : undefined}218 tabIndex={-1}219 onKeyDown={trapFocus}220 className={`${styles.panel} ${styles[size] ?? ""}`.trim()}221 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 8 }}222 animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}223 exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 4 }}224 transition={reduce ? { duration: 0.1 } : { duration: 0.22, ease }}225 >226 <div className={styles.header}>227 <h2 id={titleId} className={styles.title}>228 {title}229 </h2>230 {description && (231 <p id={descId} className={styles.description}>232 {description}233 </p>234 )}235 </div>236 <div className={styles.body}>{children ?? <DefaultBody fieldId={baseId} />}</div>237 <div className={styles.footer}>238 <button type="button" className={styles.secondary} onClick={() => setOpen(false)}>239 {cancelLabel}240 </button>241 <button242 type="button"243 className={styles.primary}244 onClick={() => {245 onConfirm?.();246 setOpen(false);247 }}248 >249 {confirmLabel}250 </button>251 </div>252 {showCloseButton && (253 <button type="button" aria-label="Close" className={styles.close} onClick={() => setOpen(false)}>254 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">255 <path d="M18 6 6 18M6 6l12 12" />256 </svg>257 </button>258 )}259 </motion.div>260 </div>261 </div>262 )}263 </AnimatePresence>,264 document.body,265 )}266 </div>267 );268}