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.
<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.
<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.
| 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
| 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 asrole="dialog"witharia-modal. None of that is Deck's, and none of it can be broken by styling. -
Give it an accessible name. Put an
idon.modal-titleand point at it witharia-labelledbyon 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
autofocuson 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
canceland callpreventDefault()— 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.targetto 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, setoverflow: hiddenon<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 below32remthey 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.
<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
.alertin the page, or a.toastif 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.drawerfrom 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
openin 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>.