Component
Dialog
The canonical modal dialog: a real menu of compound parts — trigger, portaled role=dialog panel, header/title/description, footer actions, corner close — with focus trap, Escape, scroll lock, and focus restoration.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/dialogs/dialog/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Dialogs/dialog React/Components/Dialogs/dialog import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
RefObject,
} from "react";
/**
* DevSnips React Dialog — reference implementation.
*
* A compound modal dialog following the WAI-ARIA dialog pattern: a real
* `<button>` trigger (`aria-haspopup="dialog"` + `aria-expanded`), a portaled
* `role="dialog"` panel with `aria-modal`, a focus trap, Escape-to-close,
* focus moved into the dialog on open and restored to the trigger on close,
* scroll locking, an overlay that blocks background interaction, and a
* labelled-by/described-by wiring registered by `DialogTitle` /
* `DialogDescription` (pass `aria-label` to `DialogContent` when a variant
* intentionally has no title). Nesting is supported: a module-level open
* stack ensures Escape closes only the top-most dialog, and a shared
* scroll-lock counter keeps the page locked until every dialog is closed.
*
* Composition: `<Dialog>` (root state) + `<DialogTrigger>` +
* `<DialogContent>` + `<DialogHeader>` / `<DialogTitle>` /
* `<DialogDescription>` / `<DialogFooter>` / `<DialogClose>`.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ------------------------------------------------------------------------ */
/* Module-level dialog stack + scroll lock */
/* ------------------------------------------------------------------------ */
/**
* Every open dialog registers its key here; the last entry is the top-most
* dialog. Escape handling uses the stack so only the top-most dialog closes.
*/
const openDialogKeys: string[] = [];
let scrollLockCount = 0;
let previousOverflow = "";
let previousPaddingRight = "";
function lockScroll(): void {
scrollLockCount += 1;
if (scrollLockCount === 1) {
previousOverflow = document.body.style.overflow;
previousPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
document.body.style.overflow = "hidden";
}
}
function unlockScroll(): void {
scrollLockCount -= 1;
if (scrollLockCount === 0) {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPaddingRight;
}
}
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** Focusable elements inside `root` (excludes hidden / aria-hidden nodes). */
function focusableElements(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute("disabled") && !el.hidden && el.getAttribute("aria-hidden") !== "true",
);
}
const TRIGGER_CLASSES =
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)]";
const CONTENT_CLASSES =
"fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] shadow-[var(--ds-shadow-lg)] focus:outline-none";
const HEADER_CLASSES = "flex flex-col gap-1.5 px-5 pt-5";
const TITLE_CLASSES =
"text-lg font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--ds-color-foreground)]";
const DESCRIPTION_CLASSES = "text-sm leading-5 text-[var(--ds-color-muted-foreground)]";
const FOOTER_CLASSES =
"flex flex-col-reverse gap-2 px-5 pb-5 pt-4 sm:flex-row sm:justify-end [&>button]:w-full sm:[&>button]:w-auto";
// One class constant per close-action kind — the kinds never rely on
// conflicting-utility overrides (Tailwind resolves conflicts by stylesheet
// order, not class order, so overrides of bg-*/border-* are unreliable).
const CLOSE_VARIANT_CLASSES: Record<DialogCloseVariant, string> = {
outline: TRIGGER_CLASSES,
primary:
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-primary)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-primary-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-primary)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
destructive:
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-destructive)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-destructive-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-destructive)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-destructive)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
ghost:
"absolute right-3 top-3 inline-flex size-8 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
};
interface DialogContextValue {
open: boolean;
modal: boolean;
contentId: string;
titleId: string;
descriptionId: string;
hasTitle: boolean;
hasDescription: boolean;
registerTitle(): () => void;
registerDescription(): () => void;
rememberFocus(element: HTMLElement | null): void;
requestOpen(next: boolean): void;
requestClose(): void;
triggerRef: RefObject<HTMLButtonElement>;
}
const DialogContext = createContext<DialogContextValue | null>(null);
function useDialog(component: string): DialogContextValue {
const context = useContext(DialogContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Dialog>.`);
}
return context;
}
/* ------------------------------------------------------------------------ */
/* Dialog (root) */
/* ------------------------------------------------------------------------ */
export interface DialogProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called whenever the dialog requests to open or close. */
onOpenChange?: (open: boolean) => void;
/**
* Modal behavior (default true): overlay, scroll lock, focus trap, and
* `aria-modal`. Set false for a non-modal floating panel (page stays
* interactive; close on Escape or outside pointer down).
*/
modal?: boolean;
children?: ReactNode;
}
export function Dialog({
open,
defaultOpen = false,
onOpenChange,
modal = true,
children,
}: DialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const reactId = useId();
const dialogKey = `ds-dialog${reactId}`;
const contentId = `ds-dialog-content${reactId}`;
const titleId = `ds-dialog-title${reactId}`;
const descriptionId = `ds-dialog-description${reactId}`;
const [hasTitle, setHasTitle] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
const openRef = useRef(actualOpen);
openRef.current = actualOpen;
const requestOpen = useCallback(
(next: boolean) => {
// Remember the focused element at the moment the open is REQUESTED
// (the trigger, or a row action). Capturing here — not in the open
// effect — because mounted-child effects run before the root's, so by
// effect time focus has already moved into the dialog.
if (next && !openRef.current) {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
}
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isControlled, onOpenChange],
);
const requestClose = useCallback(() => requestOpen(false), [requestOpen]);
const requestCloseRef = useRef(requestClose);
requestCloseRef.current = requestClose;
const registerTitle = useCallback(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, []);
const registerDescription = useCallback(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, []);
// Fallback focus capture for opens that never pass through requestOpen
// (e.g. a parent flipping controlled `open` directly): DialogContent calls
// this from its open effect — the first code that moves focus — while the
// pre-open focused element is still active.
const rememberFocus = useCallback((element: HTMLElement | null) => {
if (restoreFocusRef.current === null) {
restoreFocusRef.current = element;
}
}, []);
// While open: register on the dialog stack and lock body scroll (modal
// only); on close restore focus to the trigger (or whatever was focused
// when the dialog opened — captured in requestOpen).
useEffect(() => {
if (!actualOpen) return;
openDialogKeys.push(dialogKey);
if (modal) lockScroll();
return () => {
const index = openDialogKeys.indexOf(dialogKey);
if (index !== -1) openDialogKeys.splice(index, 1);
if (modal) unlockScroll();
const element = restoreFocusRef.current;
restoreFocusRef.current = null;
if (element && element.isConnected) element.focus();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen, modal, dialogKey]);
// Escape closes only the top-most open dialog (nesting-safe).
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && openDialogKeys[openDialogKeys.length - 1] === dialogKey) {
event.preventDefault();
requestCloseRef.current();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen, dialogKey]);
const value: DialogContextValue = {
open: actualOpen,
modal,
contentId,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
rememberFocus,
requestOpen,
requestClose,
triggerRef,
};
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
}
export default Dialog;
/* ------------------------------------------------------------------------ */
/* DialogTrigger */
/* ------------------------------------------------------------------------ */
export interface DialogTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode;
}
export function DialogTrigger({ children, className, onClick, ...rest }: DialogTriggerProps) {
const context = useDialog("DialogTrigger");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.requestClose();
} else {
context.requestOpen(true);
}
}
return (
<button
type="button"
ref={context.triggerRef}
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
onClick={handleClick}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{children}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* DialogContent (portal to document.body) */
/* ------------------------------------------------------------------------ */
export interface DialogContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogContent({ children, className, onKeyDown, ...rest }: DialogContentProps) {
const context = useDialog("DialogContent");
const contentRef = useRef<HTMLDivElement>(null);
const open = context.open;
// Move focus into the dialog on open: the first focusable element, or the
// dialog container itself when it has no focusable children. This effect
// is the first code that moves focus (child effects run before the root's
// open effect), so it also captures the pre-open focused element for
// externally-controlled opens that never passed through requestOpen.
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
context.rememberFocus(document.activeElement as HTMLElement | null);
const items = focusableElements(node);
(items[0] ?? node).focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Non-modal mode: an outside pointer down closes the dialog (modal closes
// via its overlay instead).
useEffect(() => {
if (!open || context.modal) return;
function onPointerDown(event: PointerEvent) {
const node = contentRef.current;
const target = event.target as Node;
const trigger = context.triggerRef.current;
if (node && !node.contains(target) && !(trigger && trigger.contains(target))) {
context.requestClose();
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, context.modal]);
// Focus trap (modal only): wrap Tab / Shift+Tab at the first / last
// focusable element. Skipped while focus lives inside a NESTED dialog —
// the nested dialog traps its own keys.
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || event.key !== "Tab" || !context.modal) return;
const node = contentRef.current;
if (!node || !node.contains(document.activeElement)) return;
const items = focusableElements(node);
if (items.length === 0) {
event.preventDefault();
node.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<>
{context.modal ? (
<div
aria-hidden="true"
data-ds-dialog-overlay=""
className={OVERLAY_CLASSES}
// Canceling pointerdown suppresses the compatibility mousedown, so
// the browser's default "focus the clicked surface" behavior never
// steals focus from the trigger the close is about to restore.
onPointerDown={(event) => {
event.preventDefault();
context.requestClose();
}}
/>
) : null}
<div
ref={contentRef}
id={context.contentId}
role="dialog"
aria-modal={context.modal ? true : undefined}
aria-labelledby={context.hasTitle ? context.titleId : undefined}
aria-describedby={context.hasDescription ? context.descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cx(CONTENT_CLASSES, className)}
{...rest}
>
{children}
</div>
</>,
document.body,
);
}
/* ------------------------------------------------------------------------ */
/* DialogHeader / DialogTitle / DialogDescription */
/* ------------------------------------------------------------------------ */
export interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogHeader({ className, children, ...rest }: DialogHeaderProps) {
return (
<div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export interface DialogTitleProps extends HTMLAttributes<HTMLHeadingElement> {
children?: ReactNode;
}
export function DialogTitle({ className, children, ...rest }: DialogTitleProps) {
const context = useDialog("DialogTitle");
// Register so DialogContent can wire `aria-labelledby` — and omit it when
// no title is rendered (content then relies on an explicit `aria-label`).
useEffect(() => context.registerTitle(), [context]);
return (
<h2 id={context.titleId} className={cx(TITLE_CLASSES, className)} {...rest}>
{children}
</h2>
);
}
export interface DialogDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
children?: ReactNode;
}
export function DialogDescription({ className, children, ...rest }: DialogDescriptionProps) {
const context = useDialog("DialogDescription");
// Register so DialogContent can wire `aria-describedby`.
useEffect(() => context.registerDescription(), [context]);
return (
<p id={context.descriptionId} className={cx(DESCRIPTION_CLASSES, className)} {...rest}>
{children}
</p>
);
}
/* ------------------------------------------------------------------------ */
/* DialogFooter / DialogClose */
/* ------------------------------------------------------------------------ */
export interface DialogFooterProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogFooter({ className, children, ...rest }: DialogFooterProps) {
return (
<div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export type DialogCloseVariant = "outline" | "primary" | "destructive" | "ghost";
export interface DialogCloseProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* `outline` (default) is the bordered footer cancel action; `primary` /
* `destructive` are confirming actions that also close; `ghost` is the
* icon-sized corner close button (positioned top-right of the panel —
* give it an `aria-label`).
*/
variant?: DialogCloseVariant;
children?: ReactNode;
}
export function DialogClose({ children, className, onClick, variant = "outline", ...rest }: DialogCloseProps) {
const context = useDialog("DialogClose");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
context.requestClose();
}
return (
<button
type="button"
onClick={handleClick}
className={cx(CLOSE_VARIANT_CLASSES[variant], className)}
{...rest}
>
{children}
</button>
);
} /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState
} from "react";
import { createPortal } from "react-dom";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const openDialogKeys = [];
let scrollLockCount = 0;
let previousOverflow = "";
let previousPaddingRight = "";
function lockScroll() {
scrollLockCount += 1;
if (scrollLockCount === 1) {
previousOverflow = document.body.style.overflow;
previousPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
document.body.style.overflow = "hidden";
}
}
function unlockScroll() {
scrollLockCount -= 1;
if (scrollLockCount === 0) {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPaddingRight;
}
}
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function focusableElements(root) {
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute("disabled") && !el.hidden && el.getAttribute("aria-hidden") !== "true"
);
}
const TRIGGER_CLASSES = "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)]";
const CONTENT_CLASSES = "fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] shadow-[var(--ds-shadow-lg)] focus:outline-none";
const HEADER_CLASSES = "flex flex-col gap-1.5 px-5 pt-5";
const TITLE_CLASSES = "text-lg font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--ds-color-foreground)]";
const DESCRIPTION_CLASSES = "text-sm leading-5 text-[var(--ds-color-muted-foreground)]";
const FOOTER_CLASSES = "flex flex-col-reverse gap-2 px-5 pb-5 pt-4 sm:flex-row sm:justify-end [&>button]:w-full sm:[&>button]:w-auto";
const CLOSE_VARIANT_CLASSES = {
outline: TRIGGER_CLASSES,
primary: "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-primary)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-primary-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-primary)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
destructive: "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-destructive)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-destructive-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-destructive)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-destructive)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
ghost: "absolute right-3 top-3 inline-flex size-8 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"
};
const DialogContext = createContext(null);
function useDialog(component) {
const context = useContext(DialogContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Dialog>.`);
}
return context;
}
function Dialog({
open,
defaultOpen = false,
onOpenChange,
modal = true,
children
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef(null);
const restoreFocusRef = useRef(null);
const reactId = useId();
const dialogKey = `ds-dialog${reactId}`;
const contentId = `ds-dialog-content${reactId}`;
const titleId = `ds-dialog-title${reactId}`;
const descriptionId = `ds-dialog-description${reactId}`;
const [hasTitle, setHasTitle] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
const openRef = useRef(actualOpen);
openRef.current = actualOpen;
const requestOpen = useCallback(
(next) => {
if (next && !openRef.current) {
restoreFocusRef.current = document.activeElement;
}
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isControlled, onOpenChange]
);
const requestClose = useCallback(() => requestOpen(false), [requestOpen]);
const requestCloseRef = useRef(requestClose);
requestCloseRef.current = requestClose;
const registerTitle = useCallback(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, []);
const registerDescription = useCallback(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, []);
const rememberFocus = useCallback((element) => {
if (restoreFocusRef.current === null) {
restoreFocusRef.current = element;
}
}, []);
useEffect(() => {
if (!actualOpen) return;
openDialogKeys.push(dialogKey);
if (modal) lockScroll();
return () => {
const index = openDialogKeys.indexOf(dialogKey);
if (index !== -1) openDialogKeys.splice(index, 1);
if (modal) unlockScroll();
const element = restoreFocusRef.current;
restoreFocusRef.current = null;
if (element && element.isConnected) element.focus();
};
}, [actualOpen, modal, dialogKey]);
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event) {
if (event.key === "Escape" && openDialogKeys[openDialogKeys.length - 1] === dialogKey) {
event.preventDefault();
requestCloseRef.current();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [actualOpen, dialogKey]);
const value = {
open: actualOpen,
modal,
contentId,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
rememberFocus,
requestOpen,
requestClose,
triggerRef
};
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
}
function DialogTrigger({ children, className, onClick, ...rest }) {
const context = useDialog("DialogTrigger");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.requestClose();
} else {
context.requestOpen(true);
}
}
return <button
type="button"
ref={context.triggerRef}
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
onClick={handleClick}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{children}
</button>;
}
function DialogContent({ children, className, onKeyDown, ...rest }) {
const context = useDialog("DialogContent");
const contentRef = useRef(null);
const open = context.open;
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
context.rememberFocus(document.activeElement);
const items = focusableElements(node);
(items[0] ?? node).focus();
}, [open]);
useEffect(() => {
if (!open || context.modal) return;
function onPointerDown(event) {
const node = contentRef.current;
const target = event.target;
const trigger = context.triggerRef.current;
if (node && !node.contains(target) && !(trigger && trigger.contains(target))) {
context.requestClose();
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, context.modal]);
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented || event.key !== "Tab" || !context.modal) return;
const node = contentRef.current;
if (!node || !node.contains(document.activeElement)) return;
const items = focusableElements(node);
if (items.length === 0) {
event.preventDefault();
node.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<>
{context.modal ? <div
aria-hidden="true"
data-ds-dialog-overlay=""
className={OVERLAY_CLASSES}
onPointerDown={(event) => {
event.preventDefault();
context.requestClose();
}}
/> : null}
<div
ref={contentRef}
id={context.contentId}
role="dialog"
aria-modal={context.modal ? true : undefined}
aria-labelledby={context.hasTitle ? context.titleId : undefined}
aria-describedby={context.hasDescription ? context.descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cx(CONTENT_CLASSES, className)}
{...rest}
>
{children}
</div>
</>,
document.body
);
}
function DialogHeader({ className, children, ...rest }) {
return <div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DialogTitle({ className, children, ...rest }) {
const context = useDialog("DialogTitle");
useEffect(() => context.registerTitle(), [context]);
return <h2 id={context.titleId} className={cx(TITLE_CLASSES, className)} {...rest}>
{children}
</h2>;
}
function DialogDescription({ className, children, ...rest }) {
const context = useDialog("DialogDescription");
useEffect(() => context.registerDescription(), [context]);
return <p id={context.descriptionId} className={cx(DESCRIPTION_CLASSES, className)} {...rest}>
{children}
</p>;
}
function DialogFooter({ className, children, ...rest }) {
return <div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DialogClose({ children, className, onClick, variant = "outline", ...rest }) {
const context = useDialog("DialogClose");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
context.requestClose();
}
return <button
type="button"
onClick={handleClick}
className={cx(CLOSE_VARIANT_CLASSES[variant], className)}
{...rest}
>
{children}
</button>;
}
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose };
export default Dialog; # Dialog
The canonical modal dialog: a real menu of compound parts — trigger, portaled role=dialog panel, header/title/description, footer actions, corner close — with focus trap, Escape, scroll lock, and focus restoration.
## Usage
```tsx
import Dialog, {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "./dialog";
<Dialog>
<DialogTrigger>Edit project</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Project settings</DialogTitle>
<DialogDescription>Changes apply to everyone in the workspace.</DialogDescription>
</DialogHeader>
<div className="px-5 py-4">…</div>
<DialogFooter>
<DialogClose>Cancel</DialogClose>
<DialogClose variant="primary" onClick={save}>Save changes</DialogClose>
</DialogFooter>
<DialogClose variant="ghost" aria-label="Close dialog"><XIcon /></DialogClose>
</DialogContent>
</Dialog>
// Uncontrolled (default) or controlled:
const [open, setOpen] = useState(false);
<Dialog open={open} onOpenChange={setOpen}>…</Dialog>
// Non-modal floating panel (no overlay, no trap, no scroll lock):
<Dialog modal={false}>…</Dialog>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Dialog, {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "./dialog";
<Dialog>
<DialogTrigger>Edit project</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Project settings</DialogTitle>
<DialogDescription>Changes apply to everyone in the workspace.</DialogDescription>
</DialogHeader>
<div className="px-5 py-4">…</div>
<DialogFooter>
<DialogClose>Cancel</DialogClose>
<DialogClose variant="primary" onClick={save}>Save changes</DialogClose>
</DialogFooter>
<DialogClose variant="ghost" aria-label="Close dialog"><XIcon /></DialogClose>
</DialogContent>
</Dialog>
// Uncontrolled (default) or controlled:
const [open, setOpen] = useState(false);
<Dialog open={open} onOpenChange={setOpen}>…</Dialog>
// Non-modal floating panel (no overlay, no trap, no scroll lock):
<Dialog modal={false}>…</Dialog>
```
## Props
### `<Dialog>`
| Name | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | — | Open state (controlled). |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the dialog requests to open or close. |
| `modal` | `boolean` | `true` | Modal behavior: overlay, scroll lock, focus trap, `aria-modal`. `false` renders a non-modal floating panel (no overlay; closes on Escape / outside pointer down). |
| `children` | `ReactNode` | — | `DialogTrigger` + `DialogContent`. |
### `<DialogTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | Visible trigger label. |
A real `<button type="button">` with `aria-haspopup="dialog"` + `aria-expanded`; every native button attribute (`disabled`, `aria-label`, …) is forwarded. Focus is restored here when the dialog closes.
### `<DialogContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `role="dialog"` panel (e.g. a larger `max-w-*`). |
| `children` | `ReactNode` | — | Header, body content, footer, close button. |
Portaled to `document.body` and rendered only while open. Labelled by `DialogTitle` / described by `DialogDescription` automatically when they are rendered; pass `aria-label` when a dialog intentionally has no visible title, and `role="alertdialog"` for confirmation dialogs (forwarded via `...rest`).
### `<DialogHeader>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the header. |
| `children` | `ReactNode` | — | `DialogTitle` + `DialogDescription`. |
### `<DialogTitle>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<h2>`. |
| `children` | `ReactNode` | — | Title text. |
Registers itself so `DialogContent` sets `aria-labelledby` only while a title exists.
### `<DialogDescription>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<p>`. |
| `children` | `ReactNode` | — | Supporting description text. |
Registers itself so `DialogContent` sets `aria-describedby` only while a description exists.
### `<DialogFooter>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the action row. |
| `children` | `ReactNode` | — | Footer actions (`DialogClose`, plain buttons, links). |
Buttons stack full-width below `sm` and lay out right-aligned inline from `sm` up.
### `<DialogClose>`
| Name | Type | Default | Description |
|---|---|---|---|
| `variant` | `"outline" \| "primary" \| "destructive" \| "ghost"` | `"outline"` | `outline` is the bordered footer cancel action; `primary` / `destructive` are confirming actions that also close; `ghost` is the icon-sized corner close button (positioned absolute top-right — give it an `aria-label` such as `"Close"`). |
| `onClick` | `(event) => void` | — | Called before the dialog closes; `event.preventDefault()` keeps the dialog open. |
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | Visible label (or the close icon for `ghost`). |
A real `<button type="button">` that requests close; every native button attribute is forwarded.
## Composition
- `Dialog` — the root provider. Owns the open state (controlled `open` + `onOpenChange`, or uncontrolled `defaultOpen`), the `modal` behavior switch, the generated ids, and the focus-restore memory.
- `DialogTrigger` — a real `<button type="button">` with `aria-haspopup="dialog"` + `aria-expanded`. Click toggles the dialog.
- `DialogContent` — the portaled `role="dialog"` panel (`aria-modal` when modal) plus, in modal mode, the overlay that blocks the background. Rendered only while open; moves focus inside on open and traps Tab while modal.
- `DialogHeader` — the header layout slot (`DialogTitle` + `DialogDescription`).
- `DialogTitle` — an `<h2>` that registers itself so the panel is labelled by it.
- `DialogDescription` — a `<p>` that registers itself so the panel is described by it.
- `DialogFooter` — the action row; buttons go full-width stacked on small screens, right-aligned inline from `sm` up.
- `DialogClose` — a real `<button>` that closes the dialog (footer cancel actions, or the header close with `variant="ghost"`).
This is the reference composition — every other variant in the family uses the same primitives and extends the same class constants, states, and accessibility model. The `ghost` close is rendered last inside `DialogContent` so it stays last in the tab order (its position is visual, via `absolute`).
## Dialog Behavior
The root `<Dialog>` owns the open state. Both modes are supported:
- **Controlled** — pass `open` + `onOpenChange`; the parent owns the state.
- **Uncontrolled** — pass `defaultOpen`; the component owns the state.
`DialogContent` renders through `createPortal` into `document.body` and only while open, so the panel never fights page stacking contexts or `overflow` clipping. Opening remembers the currently focused element (usually the trigger), moves focus to the first focusable element inside the panel (or the panel itself), registers the dialog on a module-level open stack, and — in modal mode — locks body scroll (compensating for the removed scrollbar width so the page does not shift). Closing returns focus to the remembered element, removes the stack entry, and releases the scroll lock only when no other dialog is still open, so nested dialogs never unlock the page early.
Modal dialogs close on Escape and on overlay pointer down, and trap Tab focus inside the panel. `modal={false}` renders a non-modal floating panel instead: no overlay, no scroll lock, no focus trap, and the page stays interactive — it closes on Escape or on a pointer down outside the panel.
Nesting is supported: Escape closes only the top-most open dialog (tracked by the module-level stack), and each dialog restores focus to whatever was focused when it opened — so a nested confirmation returns focus into the parent dialog, and the parent later returns focus to the page trigger. No positioning or overlay library is involved.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Enter` / `Space` (trigger) | Open the dialog, focus the first focusable element inside |
| `Tab` (modal dialog) | Move to the next focusable element; wraps from the last element back to the first |
| `Shift+Tab` (modal dialog) | Move to the previous focusable element; wraps from the first element to the last |
| `Escape` | Close the top-most open dialog and restore focus to its trigger |
| `Tab` (non-modal dialog) | Moves forward naturally — the page stays reachable |
The trigger and every action are native `<button>` elements, so Enter/Space activation follows normal browser behavior. Disabled actions use the native `disabled` attribute: they are skipped by Tab and cannot be activated.
## Accessibility
The structure follows the WAI-ARIA dialog (modal) pattern.
- The trigger is a native `<button>` with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` pointing at the panel.
- The panel is `role="dialog"` with `aria-modal="true"` in modal mode. `DialogTitle` / `DialogDescription` register themselves, and the panel wires `aria-labelledby` / `aria-describedby` only when they are present — a dialog without a visible title must pass `aria-label` to `DialogContent` (the attributes are omitted, never left pointing at nothing). Confirmation-style dialogs can pass `role="alertdialog"` through `DialogContent`.
- Focus is real DOM focus: opening moves focus into the dialog, Tab is trapped while modal, and closing restores focus to the trigger — focus is never left on an unmounted element (the restore target is checked against `isConnected`).
- The background is unreachable while modal: the overlay blocks pointer interaction, the focus trap blocks keyboard access, and `aria-modal` tells assistive technology the rest of the page is inert.
- Disabled actions carry the native `disabled` attribute, which assistive technology announces as unavailable.
Only one dialog is expected to be open per root; mounting several `<Dialog>` roots on the same page is safe because each root scopes its own ids, focus memory, and listeners — the module-level stack only coordinates Escape order and scroll locking between roots.
## States
- **Trigger (idle)** — bordered surface button per the shared control system (36px, radius-sm, `shadow-xs`).
- **Trigger (open)** — `aria-expanded="true"`; keeps the hover surface.
- **Overlay (modal)** — `var(--ds-color-overlay)` backdrop covering the viewport; pointer down on it closes the dialog.
- **Panel** — `--ds-color-surface-elevated` with a 1px `--ds-color-border` and the `--ds-shadow-lg` elevation, radius-md, centered, capped at `100dvh - 2rem` with internal column layout, per the Dialog token rules.
- **Title / description** — heading-md (18px, 600) on foreground; body-sm on `--ds-color-muted-foreground`.
- **Footer actions** — full-width stacked below `sm`, right-aligned inline from `sm` up.
- **Disabled actions** — native `disabled`: 50% opacity, no pointer events, out of the tab order.
## Responsive Behavior
The panel is `width: calc(100vw - 2rem)` up to `max-w-lg` (512px, inside the 400–640px dialog token range) and capped at `max-height: calc(100dvh - 2rem)`, so it stays inside the viewport at every width from 375px up — including small landscape screens — without page overflow. Long content scrolls inside a `min-h-0 flex-1 overflow-y-auto` body region while the header and footer stay pinned (see `dialog-scrollable`). Footer actions stack full-width below `sm` and lay out inline from `sm` up. The trigger keeps the shared 36px control height — a comfortable touch target — at every breakpoint.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface-elevated)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This dialog variant uses the semantic color, radius, shadow, typography, and motion tokens — including `color.overlay` for the backdrop and the Dialog row of the component token rules (radius-md, shadow-lg).
## Notes
Reference implementation for the Dialogs family. It establishes the shared dialog geometry (radius-md panel, max-w-lg, 20px padding rhythm, 36px controls), the overlay/elevation model, the focus-ring treatment, the portal + focus-trap + focus-restore behavior, and the nesting-safe Escape stack that every other variant extends. 511 lines UTF-8 · LF · Spaces: 2
Continue browsing