Modal

.modal goes on a native <dialog>. That single decision is most of the component: the focus trap, the escape key, the inertness of the page behind, the backdrop and the top layer all come from the element. Deck supplies the look and an open-and-close animation, and implements none of the behaviour.

When to use it

Use a modal when the reader must deal with something before continuing: confirming a destructive action, completing a short form that would lose its place inline, reading something that blocks the task. Modality is an interruption, and it should be justified by the interruption being necessary.

Open it with showModal(), never show() and never open in the markup. Only showModal() puts the dialog in the top layer, renders ::backdrop, traps focus and makes the rest of the page inert — the other two give you a styled box with none of it.

Open it, then press Escape — nothing here handles that key
<button class="btn btn-primary" onclick="document.getElementById('dx-modal').showModal()">Delete project</button> <dialog class="modal" id="dx-modal"> <form method="dialog" class="stack stack-0"> <div class="modal-header"> <h3 class="modal-title">Delete this project?</h3> </div> <div class="modal-body"> <p>Everything in it goes with it: 2,481 exports, 14 webhooks and the audit log.</p> <p class="text-muted">This cannot be undone.</p> </div> <div class="modal-footer"> <button class="btn" value="cancel">Cancel</button> <button class="btn btn-danger" value="delete">Delete project</button> </div> </form> </dialog>
<button class="btn btn-primary" onclick="document.getElementById('dx-modal').showModal()">Delete project</button>
<dialog class="modal" id="dx-modal">
  <form method="dialog" class="stack stack-0">
    <div class="modal-header">
      <h3 class="modal-title">Delete this project?</h3>
    </div>
    <div class="modal-body">
      <p>Everything in it goes with it: 2,481 exports, 14 webhooks and the audit log.</p>
      <p class="text-muted">This cannot be undone.</p>
    </div>
    <div class="modal-footer">
      <button class="btn" value="cancel">Cancel</button>
      <button class="btn btn-danger" value="delete">Delete project</button>
    </div>
  </form>
</dialog>

<form method="dialog"> is the detail worth copying. Any button inside it closes the dialog and sets returnValue to that button's value, so a confirm dialog needs no click handlers at all — read dialog.returnValue on the close event.

Animating a dialog

A dialog goes from display: none to display: block, and historically that made it impossible to animate in: the element had no rendered starting state to transition from, and on close it disappeared before any transition could run. Deck's modal animates both directions with three modern pieces and no JavaScript.

@starting-style — the state to animate from

A @starting-style block gives an element the styles it should have for the first frame it is rendered. Without it there is nothing to interpolate from and the modal simply appears.

.modal { opacity: 0; scale: .97; translate: 0 8px; }
.modal[open] { opacity: 1; scale: 1; translate: 0; }
@starting-style { .modal[open] { opacity: 0; scale: .97; translate: 0 8px; } }

transition-behavior: allow-discrete — staying visible to close

display and overlay are discrete properties: they flip instantly and cannot be interpolated. Listing them in the transition with allow-discrete makes the browser defer the flip to the end of the transition, so the modal stays rendered — and stays in the top layer — long enough to animate out.

transition: opacity var(--dur-2) var(--ease-out),
            scale var(--dur-2) var(--ease-spring),
            translate var(--dur-2) var(--ease-spring),
            overlay var(--dur-2) allow-discrete,
            display var(--dur-2) allow-discrete;

overlay is the one people miss. Without it the dialog leaves the top layer immediately on close, so it drops behind everything else and the fade-out happens somewhere the reader cannot see.

The backdrop animates separately

::backdrop is its own pseudo-element with its own transition, so it needs its own @starting-style too. Deck fades it from transparent and blurs what is behind it by 3px.

Header, body, footer

The same three-region pattern as card, with one addition that matters: .modal-body is the scrolling region. .modal caps itself at min(85dvh, 48rem) and clips, so a long body scrolls while the header and footer stay put — which means the actions are never below the fold.

The footer is pinned; only the body moves
<button class="btn" onclick="document.getElementById('dx-modal-long').showModal()">Open a long modal</button> <dialog class="modal" id="dx-modal-long"> <form method="dialog" class="stack stack-0"> <div class="modal-header"><h3 class="modal-title">Terms</h3></div> <div class="modal-body"> <p>The header and footer stay; this region scrolls.</p> <p>One.</p><p>Two.</p><p>Three.</p><p>Four.</p><p>Five.</p><p>Six.</p> <p>Seven.</p><p>Eight.</p><p>Nine.</p><p>Ten.</p><p>Eleven.</p><p>Twelve.</p> </div> <div class="modal-footer"><button class="btn btn-primary">Accept</button></div> </form> </dialog>
<button class="btn" onclick="document.getElementById('dx-modal-long').showModal()">Open a long modal</button>
<dialog class="modal" id="dx-modal-long">
  <form method="dialog" class="stack stack-0">
    <div class="modal-header"><h3 class="modal-title">Terms</h3></div>
    <div class="modal-body">
      <p>The header and footer stay; this region scrolls.</p>
      <p>One.</p><p>Two.</p><p>Three.</p><p>Four.</p><p>Five.</p><p>Six.</p>
      <p>Seven.</p><p>Eight.</p><p>Nine.</p><p>Ten.</p><p>Eleven.</p><p>Twelve.</p>
    </div>
    <div class="modal-footer"><button class="btn btn-primary">Accept</button></div>
  </form>
</dialog>

Below 32rem, .modal-footer becomes column-reverse with full-width buttons — so the primary action, which is last in the markup, is on top where a thumb reaches it, while the DOM order that screen readers and keyboards follow is unchanged.

Classes

Generated from src/07-components.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.modal deck.components A native <dialog>. Open it with showModal(), never show() and never the open attribute: only showModal() gives the top layer, the backdrop, the focus trap and an inert background. The open and close animate because display and overlay are in the transition with allow-discrete, which is what keeps the element rendered long enough to fade out. 07-components.css:680
.modal-body deck.components The scrolling region. The modal caps at min(85dvh, 48rem) and clips, so a long body scrolls while the header and footer stay put — which is what keeps the actions above the fold. 07-components.css:726
.modal-footer deck.components The action row, end-aligned. Below 32rem it becomes column-reverse with full-width buttons, so the primary action — last in the markup — is on top where a thumb reaches it, without changing DOM order. 07-components.css:730
.modal-header deck.components The title row. A flex row aligned to flex-start, so a long title and a close button line up on the first line. 07-components.css:713
.modal-title deck.components The dialog's heading. Give it an id and point aria-labelledby at it, or the dialog is announced with no name. 07-components.css:722

Tokens

Design tokens this component reads
Token Live Declared value
--surface light-dark(var(--ink-0), var(--ink-950))
--surface-2 light-dark(var(--ink-25), var(--ink-900))
--line light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral)))
--text light-dark(var(--ink-900), var(--ink-100))
--text-lg clamp(1.125rem, 1.06rem + .32vw, 1.3125rem)
--r-lg 18px
--shadow-4 0 8px 16px oklch(20% .03 var(--shadow-color) / .08), 0 24px 56px oklch(20% .03 var(--shadow-color) / .14)
--space-3 .75rem
--space-4 1rem
--space-5 1.25rem
--space-8 2rem
--dur-2 190ms
--ease-spring linear(0, .18 6%, .53 14%, .89 24%, 1.04 32%, 1.06 40%, 1)
--hue-neutral 232

Accessibility

  • The element does the hard parts. showModal() traps focus inside the dialog, makes everything else inert, closes on Escape and exposes the dialog as role="dialog" with aria-modal. None of that is Deck's, and none of it can be broken by styling.
  • Give it an accessible name. Put an id on .modal-title and point at it with aria-labelledby on the dialog. Without one, a screen reader announces "dialog" and nothing else.
  • Focus lands on the first focusable element unless you say otherwise. For a destructive confirmation that is usually wrong — put autofocus on Cancel so an accidental Enter does not delete anything.
  • Focus returns to the trigger on close, handled by the browser. This is the part hand-rolled modals most often get wrong, and it is free here.
  • Escape cannot be disabled, and should not be. If closing needs confirmation, listen for cancel and call preventDefault() — but be certain, because a reader who cannot get out of a dialog is trapped.
  • Clicking the backdrop does nothing by default. Deck adds no light-dismiss handler, which is deliberate for a confirmation. If you want it, compare event.target to the dialog itself — the backdrop counts as the dialog for click purposes.
  • The page behind still scrolls in some browsers. <dialog> makes the background inert but does not universally lock scrolling. If that matters, set overflow: hidden on <html> while it is open — Deck does not, because doing it in CSS alone is not possible.
  • Touch targets in the footer. Buttons are .btn, which clears 44px, and below 32rem they go full width.

Right to left

The dialog is centred by the browser and its regions use logical padding and borders, so the whole component mirrors with no extra rules. The footer's justify-content: flex-end follows the writing direction, putting the primary action on the left in an RTL layout — which is where a reader of Arabic expects it.

The footer actions move to the left edge
<button class="btn" onclick="document.getElementById('dx-modal-rtl').showModal()">افتح النافذة</button> <dialog dir="rtl" class="modal" id="dx-modal-rtl"> <form method="dialog" class="stack stack-0"> <div class="modal-header"><h3 class="modal-title">حذف المشروع؟</h3></div> <div class="modal-body"><p>لا يمكن التراجع عن هذا الإجراء.</p></div> <div class="modal-footer"> <button class="btn">إلغاء</button> <button class="btn btn-danger">حذف</button> </div> </form> </dialog>
<button class="btn" onclick="document.getElementById('dx-modal-rtl').showModal()">افتح النافذة</button>
<dialog dir="rtl" class="modal" id="dx-modal-rtl">
  <form method="dialog" class="stack stack-0">
    <div class="modal-header"><h3 class="modal-title">حذف المشروع؟</h3></div>
    <div class="modal-body"><p>لا يمكن التراجع عن هذا الإجراء.</p></div>
    <div class="modal-footer">
      <button class="btn">إلغاء</button>
      <button class="btn btn-danger">حذف</button>
    </div>
  </form>
</dialog>

Reduced motion

The modal scales, translates and fades over 190ms, and the backdrop fades with it. Under prefers-reduced-motion: reduce the global reset collapses every transition to .01ms, so the dialog appears and disappears instantly.

That is the correct outcome and it depends on something subtle: because display and overlay are still in the transition list, they still flip at the end — of a .01ms transition. The close still works. A modal that animated with JavaScript timers instead would need its own reduced-motion branch to avoid a dialog that never closes.

Printing

src/99-print.css sets dialog::backdrop { display: none }, so a dialog that happens to be open when the page is printed does not put a grey wash over the sheet. The dialog itself prints where it falls.

Printing with a modal open is unusual enough that Deck does not try to do more. If a modal's contents are meant to be printable — a receipt, a ticket — render them into the page as well rather than relying on the dialog.

Overriding it

/* One modal, no layer needed */
<dialog class="modal" style="inline-size:min(48rem, calc(100vw - 2rem))">

@layer app.components {
  /* A wider default */
  .modal { inline-size: min(42rem, calc(100vw - var(--space-8))); }

  /* No blur behind, for a page with a busy background */
  .modal::backdrop { backdrop-filter: none; }
}

If you replace the transition, keep overlay and display in the list with allow-discrete. Dropping them is what turns a smooth close into an instant disappearance, and it is not obvious from the symptom.

When not to use it

  • Not for a message. A modal demands a response. Something the reader only needs to know is an .alert in the page, or a .toast if it reports something that just happened.
  • Not for a long form. A form that scrolls inside a modal loses the reader's place and cannot be linked to or refreshed. Give it a page.
  • Not for filters or settings on a phone. Use .sheet, which comes from the bottom and is reachable by thumb, or .drawer from the side.
  • Not for a menu. Use .menu, which is a popover: light-dismiss, no focus trap, no backdrop. A modal for a list of five actions is a blocking interruption for something that is not one.
  • Not nested. A dialog opened from a dialog stacks two focus traps and two backdrops, and the reader has to escape twice. If a modal needs a modal, the first one should have been a page.
  • Not with open in the markup. <dialog open> renders the dialog inline, outside the top layer, with no backdrop, no focus trap and no inertness — a box that looks like a modal and behaves like a <div>.