Dialog
Native modal dialog with compound parts, configurable frame and corner treatments, and a header strip band carrying the title and its optional description.
Installation
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.
The hosted registry is not public yet because r.b4r7.dev does not resolve. Use this source checkout or a local registry preview until the endpoint returns 200.
Diffgazer packages are not yet published to npm. Until the first release, pack @diffgazer/ui and @diffgazer/keys from the repository and install those tarballs.
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
Examples
Open State (inline, non-modal)
Bracketed
Corner Marks
With Description
Alert Dialog
With Form
Sizes
Upload
Keyboard
Custom Trigger
Close Icon
Nested Popover
API Reference
Dialog
DialogContent
DialogTitle
DialogTrigger
DialogAction
DialogClose
DialogCloseIcon
DialogFooter
Data attributes
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).
| Key | Action |
|---|---|
| Escape | Closes the dialog unless onEscapeKeyDown prevents it. |
| Tab / Shift+Tab | Moves through focusable dialog content while the native modal keeps background inert. |
| Enter / Space | Activates the focused button, action, or close control. |
Default with keyboard
Keyboard hints
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.
Dialoglocks<body>. Apps that scroll on<html>, on a wrapping layout element, or inside a dedicated scroll container calluseScrollLock({ target })themselves for that element. - iOS Safari rubber-band.
overflow: hiddenon<body>does not prevent touch scrolling of the visual viewport. If preventing rubber-band is required, layer atouchmovehandler 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.