Dialogs are modal windows built on the native <dialog> element. They trap focus, darken the backdrop, and support header, body, and footer sections. Opening and closing is handled via data-dialog-open and data-dialog-close attributes.
Basic usage
<button class="button" type="button" data-dialog-open="my-dialog">Open dialog</button>
<dialog id="my-dialog" class="dialog" aria-labelledby="my-dialog-title">
<div class="dialog-header">
<h2 class="dialog-title" id="my-dialog-title">Dialog title</h2>
<button class="button close-btn" type="button" data-icon-only data-size="small"
data-dialog-close aria-label="Close"><!-- close icon --></button>
</div>
<div class="dialog-body">
<p>Dialog content here.</p>
</div>
<div class="dialog-footer">
<button class="button" data-variant="faded" type="button" data-dialog-close>Cancel</button>
<button class="button" type="button" data-dialog-close>Confirm</button>
</div>
</dialog>
Confirmation dialog
A destructive action pattern with a danger-styled confirm button.
JavaScript
Include assets/js/dialog.js on any page using dialogs.
<script src="/assets/js/dialog.js"></script>
One script serves both Dialog and Drawer — they are the same machinery, a modal <dialog> with native focus trapping, differing only in where the panel sits.
| API | Purpose |
|---|---|
data-dialog-open="dialog-id" |
On a trigger, opens that dialog via showModal() |
data-dialog-close |
Inside a dialog, closes it |
data-static |
Opt out of closing on a backdrop press |
dialog-hide |
Cancellable event fired before closing, with detail.source |
window.bdRequestClose(el, source) |
Closes through the guard |
window.bdCloseOpenDialogs() |
Force-closes every open dialog and drawer, skipping guards |
Clicking the backdrop closes the dialog unless it carries data-static.
Every listener is delegated from document, so dialogs added after page load — a CMS render, a client app, a Barba container swap — work with no re-init. window.initDialog() exists for parity with the other modules, but calling it again is a no-op.
Guarding the close
Every user-driven close fires a cancellable dialog-hide first. preventDefault() keeps the dialog open and pulses it, so a blocked close reads as deliberate rather than broken. event.detail.source is "close-button", "backdrop" or "escape".
dialog.addEventListener('dialog-hide', (event) => {
if (event.detail.source === 'close-button') return;
if (hasUnsavedChanges()) event.preventDefault();
});
Two limits worth knowing. A direct dialog.close() bypasses the guard — route programmatic closes through window.bdRequestClose(dialog, 'programmatic') instead. And two Escape presses in a row with no interaction between them always close: the browser only makes the first close request cancellable. Treat the guard as protection against an accidental close, never as the only thing preventing lost work.
Placement
Where the <dialog> sits in the document matters more than it looks.
- Put it as a direct child of
<body>, or at least outside[data-barba="container"]. A dialog destroyed mid-transition while open leaves the browser's top layer and focus in an inconsistent state - Never nest it inside an existing
<form>. The HTML parser drops nested forms, which silently breaks any<form method="dialog">inside the dialog - Give every non-submitting control
type="button". Inside a<form>, buttons default totype="submit"
Motion
The dialog rises into place and drops away: it enters from --dialog-offset below, settling upward as it fades in, and exits back down. The entrance is the slower half — --duration-m on --ease-out, which has a long graceful tail — while the exit clears at --duration-s on --ease-in. Opening should feel considered; closing should get out of the way.
The backdrop fades on the same timing.
Retiming it is a token change, not a rule override:
.dialog {
--dialog-offset: var(--space-2xl);
--dialog-enter-duration: var(--duration-s);
}
Under prefers-reduced-motion: reduce the travel drops to zero and both directions shorten to --duration-2xs, leaving a plain fade. The transition is retimed rather than removed, so the discrete display and overlay steps still run and the dialog can never be stranded mid-exit.
Layout properties (flex-direction, gap) sit on , not on .dialog[open]. Only display is discretely transitioned, so anything else left on [open] snaps back the moment the attribute is removed and the dialog visibly reflows while it is still fading out. If you add a layout property to a dialog, put it on the base.
This relies on @starting-style and transition-behavior: allow-discrete — the only way a top-layer element can animate out of display: none. Engines without them show and hide the dialog instantly, with no broken intermediate state.
Keyboard interactions
| Key | Action |
|---|---|
Escape |
Closes the dialog, unless a dialog-hide listener cancels it |
Tab |
Cycles through focusable elements inside the dialog (focus is trapped) |
Enter / Space |
Activates the focused button |
Accessibility
- Uses the native
<dialog>element. Focus trapping, the backdrop, and returning focus to the trigger on close are all handled by the browser aria-labelledbyis required, pointing at the ID. Without it the dialog has no accessible name and screen readers announce a bare "dialog"- Close buttons must have
aria-label="Close" - Match the heading level to the surrounding page outline. The class carries the styling, so the level is free
- Set
autofocuson the control the user should land on. Without it the entry point varies by browser engine. On a destructive dialog put it on Cancel, never on the destructive action - Don't add
role="dialog",aria-modal, oraria-haspopup.showModal()supplies the role and the modality, and the rest of the document is made inert automatically — a JS focus trap is not needed and will fight the browser - For a text-only dialog whose body scrolls, add
tabindex="0"to so keyboard users can reach the scroll container
Usage rules
Do:
- Use dialogs for actions that require confirmation or focused input
- Keep dialog content concise, one task per dialog
- Always provide a close button, and keep Escape working
Don't:
- Don't use dialogs for content that should be inline on the page
- Don't stack dialogs on top of each other
- Don't use dialogs for simple alerts. Use callouts or toasts instead
- Don't rely on backdrop dismissal alone for a dialog holding unsaved input — a stray click discards it. Add
data-static, or listen fordialog-hideand callpreventDefault(). Don't listen for the nativecancelevent: the script intercepts it so Escape runs through the same guard as every other close path
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
Re-point these to re-skin the dialog for a brand. Never override the rules themselves.
| Token | Default (Light) | Default (Dark) | What it controls |
|---|---|---|---|
| Dialog surface | |||
0 8px 32px var(--black-alpha-20) |
0 8px 40px var(--black-alpha-60) |
Drop shadow | |
0 |
– | Corner radius | |
| – | Inner padding | ||
560px |
– | Maximum width | |
| – | Backdrop overlay | ||
| – | Distance travelled on enter and exit | ||
| – | Entrance duration | ||
| – | Entrance easing | ||
| – | Exit duration | ||
| – | Exit easing |
--dialog-max-width is declared in :root; the rest sit on , so a scoped [data-theme] region themes its own dialogs. --dialog-backdrop falls back to --background-modal for engines that predate ::backdrop inheriting from its originating element.
Selectors
| Selector | Purpose |
|---|---|
Base component: surface, max-width, shadow, padding, 85dvh max height, column layout, and the closed / exit state |
|
.dialog::backdrop |
Backdrop overlay colour |
.dialog[open] |
Open state: display plus the settled opacity / translate. Layout deliberately lives on — see Motion |
@starting-style .dialog[open] |
Pre-open state that makes the entrance animatable |
.dialog.is-pulsing |
Blocked-close feedback, shared with Drawer |
| Flex row for title + close button | |
| Dialog heading | |
Scrolling content area (min-height: 0 so the footer can't be pushed out) |
|
| Footer with flex-end alignment for action buttons | |
Deprecated bespoke close button, kept for existing markup. Use .button.close-btn |
|
[data-theme="dark"] .dialog |
Dark mode: re-points and |
The close button is the shared role class, documented in Button. It is not a dialog-specific component.
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship in design-system.css. Its behaviour ships as dist/js/dialog.js — copy it into the product's served assets and include it once per page:
<script src="assets/js/dialog.js" defer></script>
In React, render this contract through the packaged adapter instead of writing the markup by hand:
import { Dialog } from '@bydefaultstudio/design-system/react';
The adapter renders the contract above and bridges this component's events to props — see React.