Skip to content

Dialog

Native modal dialog with compound parts, configurable frame and corner treatments, and a header strip band carrying the title and its optional description.

Preview

Installation

$pnpm exec dgadd add ui/dialog
[Installs to]src/components/ui/dialog[Item]ui/dialog

dgadd is not public on npm yet. Until the first release, pack @diffgazer/add from the repository and install that tarball into this app, which is what puts dgadd on pnpm exec.

UI components require Tailwind CSS v4. Local copy mode imports src/styles/styles.css; package mode uses @diffgazer/ui CSS once packages are available.

Usage

tsx
import {  Dialog,  DialogAction,  DialogBody,  DialogClose,  DialogContent,  DialogFooter,  DialogHeader,  DialogTitle,  DialogTrigger,} from "@/components/ui/dialog";export default function DialogDefault() {  return (    <Dialog>      <DialogTrigger>Open Dialog</DialogTrigger>      <DialogContent>        <DialogHeader>          <DialogTitle>Apply Patch</DialogTitle>        </DialogHeader>        <DialogBody>          <p className="text-sm text-muted-foreground leading-relaxed">            This will modify <span className="text-foreground font-bold">2 files</span> in{" "}            <span className="text-foreground font-mono">src/auth/</span>.            <br />            Continue?          </p>        </DialogBody>        <DialogFooter hints={[{ key: "Esc", label: "Close" }]}>          <DialogClose bracket>Cancel</DialogClose>          <DialogAction>Apply</DialogAction>        </DialogFooter>      </DialogContent>    </Dialog>  );}

Examples

Open State (inline, non-modal)

Preview

Bracketed

Preview

Corner Marks

Preview

With Description

Preview

Alert Dialog

Preview

With Form

Preview

Sizes

Preview

Upload

Preview

Keyboard

Preview

Custom Trigger

Preview

Close Icon

Preview

Nested Popover

Preview

API Reference

Dialog

NameTypeDefaultDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state for uncontrolled usage.
onOpenChange(open: boolean) => voidCalled whenever open state changes (trigger click, Escape, backdrop click, programmatic close).

DialogContent

NameTypeDefaultDescription
size"sm" | "md" | "lg" | "full""md"Modal width preset.
frame"border" | "none""border"Border frame style. "border" renders a 1px border around the dialog. "none" removes the border (pair with corners for a frameless viewfinder look).
corners"none" | "subtle" | "standard" | "bold" | "outset""none"Corner accent marks drawn at the dialog corners. "none" skips them. "subtle" uses border color and tighter 12px arms. "standard" uses foreground color 18px arms. "bold" uses foreground color 28px arms. "outset" is standard shifted 3px outside the dialog edge. Combine with frame="none" for a pure viewfinder look or frame="border" for a bracketed-frame look.
role"dialog" | "alertdialog""dialog"Set role="alertdialog" for destructive confirmations. Per WAI-ARIA APG, alert dialogs should not close on outside interaction. Modal mode only — an inline dialog is a labelled region, not a dialog.
modalbooleantrueRenders the dialog as a native modal in the browser top layer. Pass false to render the same frame, corners, and chrome in the document flow instead — no backdrop, focus trap, scroll lock, or focus restoration, and role="group" instead of a dialog role.
closeIconbooleantrueRenders the top-right [x] close control on a modal dialog. Pass false to opt out when the dialog owns its own dismissal affordance. Inline dialogs never render it — compose DialogCloseIcon explicitly there.
closeOnBackdropClickbooleantrueWhen false, clicking the backdrop does not close the dialog (recommended for alertdialog).
initialFocusRefObject<HTMLElement | null>Element ref to focus when the dialog opens. Use it for alertdialog flows where focus should start on the safest action, such as Cancel.
onEscapeKeyDown(e: SyntheticEvent<HTMLDialogElement>) => voidIntercept Escape. Call e.preventDefault() to keep the dialog open during async operations.
onCancel(e: SyntheticEvent<HTMLDialogElement>) => voidNative cancel handler. Defaults to closing the dialog.

DialogTitle

NameTypeDefaultDescription
as"h1" | "h2" | "h3" | "h4" | "h5" | "h6""h2"Heading level for the title element.
metastringOptional eyebrow tag (e.g. "CONFIRM", "DESTRUCTIVE"). Sits at the title row inline end, except when a DialogCloseIcon is present — then the close button owns the top-right corner and the eyebrow moves next to the title text instead. Rendered as dialog content but outside the heading, so it is excluded from the dialog accessible name.

DialogTrigger

NameTypeDefaultDescription
childrenrequiredReactNode | (renderProps: DialogTriggerRenderProps) => ReactNodeTrigger button or render function. The render form receives ref, className, aria-haspopup/expanded/controls, and onClick.

DialogAction

NameTypeDefaultDescription
onClick(e: MouseEvent<HTMLButtonElement>) => voidPrimary action handler. Call e.preventDefault() to keep the dialog open (e.g. failed form validation).

DialogClose

NameTypeDefaultDescription
onClick(e: MouseEvent<HTMLButtonElement>) => voidClose handler. Call e.preventDefault() to keep the dialog open.
aria-labelstringExplicit accessible name. aria-labelledby wins when both attributes are set. With neither attribute, visible child text names the button; empty, decorative, or hidden content falls back to "Close dialog".
aria-labelledbystringID of the element that labels the close button. It takes precedence over aria-label and suppresses the automatic "Close dialog" fallback.

DialogCloseIcon

NameTypeDefaultDescription
aria-labelstring"Close dialog"Accessible name for the close button. Override for localization or alternative phrasing.

DialogFooter

NameTypeDefaultDescription
hintsKeyboardHint[]Inline keyboard shortcut hints rendered alongside the action buttons. Use the shorthand instead of composing DialogFooter.Hints when the hints belong with the footer actions.

Data attributes

AttributeApplies toValuesDescription
data-stateDialogContent"open" | "closed"Native dialog open state mirrored by the shared shell.
data-frameDialogContent"border" | "none"Border frame style.
data-cornersDialogContent"none" | "subtle" | "standard" | "bold" | "outset"Corner accent treatment.

Accessibility

Keyboard Navigation

Dialog uses the native <dialog> element for modal behavior. Escape closes the dialog and focus is automatically restored to the trigger. Tab cycles focus between focusable elements within the dialog (native inert background). Enter activates the focused button (DialogAction or DialogClose).

KeyAction
EscapeCloses the dialog unless onEscapeKeyDown prevents it.
Tab / Shift+TabMoves through focusable dialog content while the native modal keeps background inert.
Enter / SpaceActivates the focused button, action, or close control.

Default with keyboard

Preview

Keyboard hints

Preview

Notes

Requires @diffgazer/keys (package mode)

DialogContent's focus restore (returning focus to the trigger on close) imports from the required @diffgazer/keys peer. Package consumers need @diffgazer/keys installed alongside @diffgazer/ui. Diffgazer packages are not yet published to npm; until the first release, install both from locally packed tarballs. Importing @diffgazer/ui/components/dialog without keys fails at module load with an error naming the missing @diffgazer/keys package. Copy/dgadd consumers do not need the package — copy mode rewrites the focus-restore hook to local source.

Compound Architecture

Dialog is composed of Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter (with DialogFooter.Hints and DialogFooter.Actions sub-components), DialogClose, DialogAction, and DialogCloseIcon.

Header strip

DialogHeader is a single form: a 44px band one surface step above the body (--surface-2) closed by a bottom hairline, laying its children out in one row. A DialogTitle renders as the 14px/700 mono title in the accent tone (--info-text) and a DialogDescription beside it as the 12px muted subtitle. Pass className to override padding, background, or direction — the overrides merge via tailwind-merge.

Close control

A modal DialogContent renders the top-right [x] (DialogCloseIcon) by default, so every modal dialog carries a pointer dismissal affordance. Pass closeIcon={false} to opt out when the dialog owns its own dismissal control. Inline dialogs (modal={false}) never render it — compose DialogCloseIcon yourself there, last inside DialogContent so it stays the final tab stop.

Native Dialog

DialogContent uses the native <dialog> element with showModal(), which provides built-in focus management, inert background, top-layer rendering, and accessible modal semantics. Focus is restored to the trigger on close.

Scroll lock

Background scroll lock has exactly one owner, and it is not the stylesheet. showModal() does not lock background scroll, so DialogContent runs useScrollLock (reference counted, scrollbar-width compensated) while it is open and modal — that lock is also the one that handles custom scroll roots and stacked dialogs. The CSS-only body lock shared/dialog.css used to ship alongside it is gone: two locks compensated for the same scrollbar twice, and the page jumped on every open. Consumers driving a raw <dialog> off the stylesheet alone therefore get no automatic lock and own that themselves (useScrollLock drops in). Inline dialogs (modal={false}) never lock.

Alert Dialog

Set role="alertdialog" and closeOnBackdropClick={false} on DialogContent for destructive confirmations. Screen readers announce it as an alert requiring immediate attention. Per WAI-ARIA APG, alert dialogs should not close on outside interaction, and focus should start on the safest action (e.g., Cancel) using initialFocus.

Escape Interception

Pass onEscapeKeyDown to DialogContent to intercept cancelable Escape dismissal. Call e.preventDefault() to keep the dialog open during async operations or form validation; if the native dialog is force-closed without a cancelable cancel event while React open is still true, the shell reopens it.

Nested popovers

An open Popover inside Dialog owns the first Escape press, including when focus is on a dialog sibling. Escape closes only the popover; focus returns to its trigger only when focus was inside the popover or trigger.

Preventing Close on Action

DialogAction and DialogClose check e.defaultPrevented. Call e.preventDefault() in your onClick handler to keep the dialog open — useful for async validation where you want to close only on success.

Keyboard hints

Pass a hints array to DialogFooter (or compose DialogFooter.Hints) to render inline keyboard shortcut hints alongside the action buttons. Hints render through the shared OverlayHints primitive (registry/ui/shared/overlay-hints), so Dialog, CommandPalette, and any future keyboard surface spell their legend the same way. Dialog opts out of the primitive's aria-hidden default so the key names stay discoverable by assistive technology. At coarse pointer the legend collapses — a key hint is instructions a touch user cannot follow — so keep any touch-relevant action in the action row, not the hints.

Corner clearance

The corner brackets own the dialog corners, so the content insets around them. corners="bold" draws 28px arms and pushes the footer actions inward so a button corner never collides with the bottom brackets, and the DialogCloseIcon inset grows per corner variant. The close icon owns the top-right corner alone: when a DialogTitle also carries a meta eyebrow, the eyebrow drops out of the corner and sits beside the title text instead, and the title row reserves the button's slot so a long (truncating) title stays clear of it.

Inline (non-modal)

Pass modal={false} to DialogContent to render the same frame, corners, header strip, and footer in the document flow — no backdrop, focus trap, scroll lock, or focus restoration. Because nothing is modal about it, the inline shell exposes role="group" (still named by DialogTitle) rather than a dialog role, and the role prop is ignored. Use it to embed dialog chrome in a page, or to make the open state visible on a static page — see the Open State example. Inline content still honours open, so it unmounts when the consumer closes it. DialogContentProps is a discriminated union on modal, so each arm types it as a literal (modal?: true or modal: false) and a boolean variable satisfies neither: branch on the variable and render the arm you mean.

Surface and backdrop

DialogContent is the modal overlay tier: --surface-1 fill (one step off the page background) with a 1px --surface-1-highlight inner lip, 1px border under the default frame='border', rounded-sm corners, and --shadow-hard (a hard 4px offset with no blur) — the library's only sanctioned shadow, reserved for this tier. Anchored overlays such as Popover and Menu submenus share the fill and lip but drop the slab shadow. The backdrop dims with --scrim over a 2px blur; the dim carries the layer separation, not the blur.

Narrow-viewport geometry

Below 640px DialogContent insets 12px from each viewport edge (max-sm:mx-3 with a matching width and max-w-none) so both vertical hairlines, the offset shadow, and the corner brackets render whole instead of being clipped at the edge. It also pads its bottom by env(safe-area-inset-bottom) to keep the footer's action row clear of the home indicator — that requires viewport-fit=cover on the host page and resolves to 0 without it. The height cap stays max-h-[90dvh], and nothing changes at 640px and up.

Entrance motion

The overlay family runs one entrance vector: opacity plus a 4px translateY drop, never a scale — a scale-in reads as a rubbery soft-UI surface, and on a wide dialog it grows the drawn border across every open. The modal tier owns its entrance clock: --dialog-duration is 150ms, because a dialog takes over the viewport and reads as a deliberate open, while the anchored tier (Select, Popover, Tooltip) stays on the 60ms --ui-content-* tokens where anything slower feels laggy under the pointer. The exit stays on --ui-content-exit-duration, and --dialog-duration remains a working per-instance override. prefers-reduced-motion: reduce drops the animation entirely.

Extending DialogContent styles

dialogContentVariants is the CVA used by DialogContent. Re-export it to compose custom variants for product-specific dialog shells — e.g. extend the base classes with bg/border tokens, or add new size keys. The corners prop is a plain TypeScript type (DialogCorners) whose visual styling is driven by [data-corners] selectors in shared/dialog.css.

Scroll lock

Native <dialog>.showModal() does not lock background scroll, so Dialog.Content locks it in JavaScript: useScrollLock from @diffgazer/keys, pointed at the dialog's own ownerDocument.body and enabled while the dialog is open and modal.

The lock is reference counted, so a stack of dialogs takes one lock and the last one to close restores the body's original overflow and padding-right. Compensation is measured rather than reserved: the hook adds the scrollbar's width to padding-right for the life of the lock, so hiding the scrollbar does not shift the page sideways. Inline dialogs (modal={false}) never lock — nothing is modal about them.

Limits to be aware of:

  • Custom scroll roots. Dialog locks <body>. Apps that scroll on <html>, on a wrapping layout element, or inside a dedicated scroll container call useScrollLock({ target }) themselves for that element.
  • iOS Safari rubber-band. overflow: hidden on <body> does not prevent touch scrolling of the visual viewport. If preventing rubber-band is required, layer a touchmove handler on top.

Copy-mode installs pull keys/scroll-lock as a registry dependency of dialog, so the hook arrives with the component. A raw <dialog> driven off dialog.css alone gets no background lock and owns that itself.

Source

Install via CLI: pnpm exec dgadd add ui/dialog. Keyboard hooks are included as standalone copies. For the full experience, use --integration keys.

Highlighted source loads after this disclosure opens. Browse the source repository.