UIOverlays and Menusmotion/react

Dialog

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

Installation

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

npx motionfoundry add dialog

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

example.tsx
1import { Dialog } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Dialog />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
triggerLabel

Label of the trigger button.

string'Edit profile'
title

Dialog title, used as the accessible name.

string'Edit profile'
description

Supporting text under the title, used as the accessible description.

string"Make changes to your profile here. Click save when you're done."
children

Body content; defaults to a small Name/Username form.

React.ReactNodeprofile form
confirmLabel

Label of the primary action.

string'Save changes'
cancelLabel

Label of the secondary action.

string'Cancel'
onConfirm

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

() => voidundefined
size

Panel max-width preset (22rem, 28rem, 36rem).

'sm' | 'md' | 'lg''md'
accentColor

Color of the primary action and input focus ring.

string'#6366f1'
backdropBlur

Backdrop blur radius in pixels.

number6
closeOnOutsideClick

Close the dialog when the backdrop is clicked.

booleantrue
showCloseButton

Show the X close button in the top-right corner.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/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/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}