Component
Confirmation Dialog
The proceed-or-cancel pattern as `role="alertdialog"`: a focused question, the safe action focused first, and the confirming action on the trailing edge.
- Component
- React
- TSX
- Tailwind CSS
- MIT
What it does
Capabilities
- Role=dialog semantics
- Focus trap
- Focus restoration
- Scroll lock
- Escape to close
- Role=alertdialog
- Safe action focused first
- Aria-haspopup="dialog" trigger
- Role="dialog" panel
- Aria-modal when modal
- Aria-labelledby + aria-describedby wiring
- Focus trap while modal
Preview
Live component
9:41 100%
devsnips.site/library/react/components/dialogs/dialog-confirmation/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Dialogs/dialog-confirmation React/Components/Dialogs/dialog-confirmation 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 — Confirmation.
*
* The shared dialog core composed as a confirmation: `role="alertdialog"`,
* a focused question with a short description, the safe action first in the
* DOM (it receives the initial focus), and the confirming action last.
*/
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; # Confirmation Dialog
The proceed-or-cancel pattern as `role="alertdialog"`: a focused question, the safe action focused first, and the confirming action on the trailing edge.
## Usage
```tsx
import Dialog, {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "./dialog-confirmation";
<Dialog>
<DialogTrigger>Discard draft</DialogTrigger>
<DialogContent role="alertdialog">
<DialogHeader>
<DialogTitle>Discard this draft?</DialogTitle>
<DialogDescription>This will permanently delete the draft.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose>Keep editing</DialogClose>
<DialogClose variant="primary" onClick={discard}>Discard draft</DialogClose>
</DialogFooter>
</DialogContent>
</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-confirmation";
<Dialog>
<DialogTrigger>Discard draft</DialogTrigger>
<DialogContent role="alertdialog">
<DialogHeader>
<DialogTitle>Discard this draft?</DialogTitle>
<DialogDescription>This will permanently delete the draft.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose>Keep editing</DialogClose>
<DialogClose variant="primary" onClick={discard}>Discard draft</DialogClose>
</DialogFooter>
</DialogContent>
</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"`).
A confirmation is the reference composition minus the corner close: a focused question, a short description, and two footer actions. The safe action (`Keep editing`) is first in the DOM, so it is what focus lands on when the dialog opens — the destructive path always requires a deliberate move.
## Dialog Behavior
`role="alertdialog"` (forwarded through `DialogContent`'s props) tells assistive technology this is an alert requiring an immediate response; everything else — focus trap, Escape, overlay close, scroll lock, focus restoration — behaves exactly like the reference dialog.
Focus order is the safety mechanism: the cancel action is first in the DOM so opening the dialog focuses it, and the confirming action is last. On small screens the footer flips visual order (`flex-col-reverse`) so the confirming action stays visually primary while the DOM order — and therefore the focus order — is unchanged.
## 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.
Escape and the overlay both cancel the confirmation (the safe default); only activating the confirming action proceeds.
## 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
For destructive confirmations (delete, revoke) use `dialog-destructive`, which pairs this pattern with the destructive action styling and a stronger warning presentation. 500 lines UTF-8 · LF · Spaces: 2
DevSnips AI ready Claude · Cursor · Windsurf
Agent skill & Model Context Protocol
Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.
devsnips://components/dialogs/dialog-confirmation I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/dialogs/dialog-confirmation/ Install: npx devsnips add React/Components/Dialogs/dialog-confirmation This is the Confirmation Dialog — The proceed-or-cancel pattern as `role="alertdialog"`: a focused question, the safe action focused first, and the confirming action on the trailing edge. Use this DevSnips resource as the starting point for my project. Adapt it to my project's stack and conventions, fix any issues, and make it production-ready while preserving its intended design and behavior.
Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.
Resource URI
devsnips://components/dialogs/dialog-confirmation