DialogMOL
Modal window built on native <dialog> + showModal(). Focus trap, Escape key, and backdrop are all browser-native. Animated with @starting-style.
On this page (6)
§Form Dialog
Dialog with form inputs. Click 'Edit Profile' to open.
§Confirmation
Destructive action confirmation. Click 'Confirm Delete' to open.
§Density
Set data-density on the {''} root to scale the content padding. A whitespace policy, not a zoom: only padding scales (ratio 0.75 / 1 / 1.25), typography and the width ladder (data-size) stay identical. comfortable matches the unsized default.
§States
Named states via the shared State API, driven per instance through the bound api:
default- closed (the authored state)open- shown modally viashowModal()
The demo dialog carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/dialog-{state}.png.
Machine contract - verified against dialog.schema.json by bun run verify (§13–§18):
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | Modal visibility - observed via the native open property; true requires showModal() (or the showModal action), false via close(). |
§CSS view file
/* -- Dialog component ------------------------------------------ */@layer components { dialog.dialog { border: none; border-radius: var(--radius-xl); background-color: var(--popover); color: var(--popover-foreground); padding: 0; margin: auto; position: fixed; inset: 0; max-width: 28rem; width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); box-shadow: 0 25px 80px oklch(0 0 0 / 0.25), 0 0 0 1px var(--border); opacity: 0; transform: translateY(-0.5rem) scale(0.98); transition: opacity 200ms ease, transform 200ms ease, display 200ms allow-discrete; &[open] { opacity: 1; transform: translateY(0) scale(1); } /* -- Sizes ------------------------------------------------ */ &[data-size="sm"] { max-width: 24rem; } &[data-size="md"] { max-width: 28rem; } &[data-size="lg"] { max-width: 32rem; } &[data-size="xl"] { max-width: 40rem; } &[data-size="full"] { max-width: calc(100vw - 2rem); } /* -- Backdrop --------------------------------------------- */ &::backdrop { background: oklch(0 0 0 / 0); backdrop-filter: blur(0px); transition: all 200ms ease, display 200ms allow-discrete; } &[open]::backdrop { background: oklch(0 0 0 / 0.45); backdrop-filter: blur(3px); } } @starting-style { dialog.dialog[open] { opacity: 0; transform: translateY(-0.5rem) scale(0.98); } dialog.dialog[open]::backdrop { background: oklch(0 0 0 / 0); backdrop-filter: blur(0px); } } /* -- Scroll lock ------------------------------------------- */ /* While the dialog is modal the page behind must stay put - closing returns you to exactly the scroll offset you had. `:modal` matches only while opened via showModal(); overflow:hidden freezes the viewport without losing the scroll position (no JS position:fixed hack), and scrollbar-gutter:stable keeps the scrollbar's space reserved so its removal can't shift the layout underneath. */ html:has(dialog.dialog:modal) { overflow: hidden; scrollbar-gutter: stable; } /* -- Density ------------------------------------------------ data-density on the .dialog root scales the content padding (0.75 / 1 / 1.25 of the 1.5rem default); comfortable matches the unsized default. Width stays governed by data-size - two axes. */ .dialog:where([data-density="compact"]) .dialog-content { padding: 1rem; } .dialog:where([data-density="comfortable"]) .dialog-content { padding: 1.5rem; } .dialog:where([data-density="spacious"]) .dialog-content { padding: 2rem; } /* -- Content sections ------------------------------------- */ .dialog-content { padding: 1.5rem; } .dialog-header { margin-bottom: 1rem; } .dialog-title { font-size: 1.0625rem; font-weight: 600; margin: 0 0 0.375rem; letter-spacing: -0.01em; } .dialog-description { font-size: 0.875rem; color: var(--muted-foreground); margin: 0; line-height: 1.6; } .dialog-body { margin-top: 1rem; } .dialog-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem; }}/* Accessibility: reduced motion removes open/close transitions (REQUIRED for all components - AGENTS.md "Accessibility CSS"). */@media (prefers-reduced-motion: reduce) { @layer components { dialog.dialog, dialog.dialog::backdrop { transition: none; } }}§JavaScript view file
Wire triggers via data-dialog-trigger, close buttons via data-dialog-close, and backdrop click. Focus returns to trigger on close.
// -- Dialog ---------------------------------------------------// Wires [data-dialog-trigger] buttons to <dialog> elements, plus the// named-state API so agents/tests can drive states by name (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const dialogStates = ['default', 'open'];/** * UI side of setState: 'default' closes, 'open' opens modally. Native * <dialog> can't animate to a declared state it's not in, so this is a * direct showModal()/close() dispatch; unknown names are rejected upstream. */function triggerStateChange(dialog, stateName, _config) { switch (stateName) { case 'default': if (dialog.open) dialog.close(); break; case 'open': if (!dialog.open) dialog.showModal(); break; }}/** Registry-level API; pass the dialog element explicitly. Unknown names throw. */export const dialogApi = { setState(dialog, stateName, config = {}) { if (!dialogStates.includes(stateName)) { throw new Error(`dialog: unknown state "${stateName}" (supported: ${dialogStates.join(', ')})`); } triggerStateChange(dialog, stateName, config); // state lives on the ELEMENT, not the module (multiple dialogs per page) dialog.dataset.stateName = stateName; dialog._stateConfig = config; }, getState(dialog) { return { name: dialog.dataset.stateName || 'default', config: dialog._stateConfig ?? {} }; },};df$.dialogApi = dialogApi;df$.dialogStates = dialogStates;function init() { document.querySelectorAll('[data-dialog-trigger]:not([data-init])').forEach((trigger) => { trigger.dataset.init = ''; const dialog = document.getElementById(trigger.dataset.dialogTrigger); if (!dialog) return; trigger.addEventListener('click', () => { dialog._trigger = trigger; dialog.showModal(); }); }); /* .command excluded: the command component owns its dialogs (own backdrop close, filtering, focus). Without this, dialog.js - which loads first — claims them via data-init and command.js's init silently skips them. */ document.querySelectorAll('dialog:not(.alert-dialog):not(.sheet):not(.command):not(.window):not(.cookie-consent-dialog):not([data-init])').forEach((dialog) => { dialog.dataset.init = ''; // bind-scope the api per instance: `$('#confirm').api.setState('open')` dialog.api = { setState: (stateName, config) => dialogApi.setState(dialog, stateName, config), getState: () => dialogApi.getState(dialog), }; dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); }); dialog.querySelectorAll('[data-dialog-close]').forEach((btn) => { btn.addEventListener('click', () => { dialog.close(); }); }); dialog.addEventListener('close', () => { // `close` fires AFTER the exit transition (display allow-discrete), so a // fast re-open can beat it - a stale event must not downgrade an open // dialog back to 'default' or yank focus out of it while it's showing. if (dialog.open) return; // reflect the actual UI state: any close path (Escape, backdrop, close // button) returns the dialog to 'default', even when it wasn't setState'd dialog.dataset.stateName = 'default'; if (dialog._trigger) dialog._trigger.focus(); }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub