⚠️ Design system CSS not found. Check the path in cms/docs.config.js → designSystemPath, then re-run npm run docgen.

Dialog

Focused interactions that require a response

Website / Dialog
Download .md file
Open .md in new tab
On this page
  • Basic usage
  • Confirmation dialog
  • JavaScript
  • Placement
  • Motion
  • Keyboard interactions
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product

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

Dialog title

This is the dialog body content. It can contain any HTML: text, forms, images, or other components.

<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.

Delete project?

This action cannot be undone. All files, settings, and history for this project will be permanently deleted.


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 to type="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-labelledby is 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 autofocus on 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, or aria-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 for dialog-hide and call preventDefault(). Don't listen for the native cancel event: 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.

On this page
  • Basic usage
  • Confirmation dialog
  • JavaScript
  • Placement
  • Motion
  • Keyboard interactions
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product
Previous Dropdown
Next Drawer

Was this page helpful?

We use this feedback to improve our documentation.

Thanks for your feedback

Send feedback

© 2026 By Default