COMPONENT
Modal
A window over the page, holding anything the system can draw. It opens, closes, takes the keyboard and hands focus back with no JavaScript at all — every part of that is the browser's, and this component only paints.
Press it, then press Escape
<button class="btn" command="show-modal" commandfor="modal-brief">OPEN THE BRIEF</button>
<dialog class="modal" id="modal-brief">
<div class="modal-box">
<div class="modal-head">
<h2 class="modal-title">Send a brief</h2>
<button class="modal-close" command="close" commandfor="modal-brief" aria-label="Close"></button>
</div>
<div class="modal-body flow">
<p>Everything that opens, closes, traps focus and gives it back here is the
browser's. Press Escape. Press Tab until it comes round again. Click the
veil beside this card.</p>
<p>The only script on this page is the one that copies a code block.</p>
</div>
<div class="modal-foot">
<button class="btn btn-quiet" command="close" commandfor="modal-brief">CANCEL</button>
<button class="btn btn-primary" command="close" commandfor="modal-brief">SEND</button>
</div>
</div>
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
</dialog>A modal is exactly the component people install a library to get right, and a hand-rolled one is how a page loses its keyboard. So this one is a <dialog> and nothing else. Opening and closing are command and commandfor, plain HTML attributes on a plain button. Escape, the top layer, the backdrop, the focus trap, the return of focus to whatever opened it, and the fact that the page behind is announced as inert — all of it is the platform's, and none of it is declared here or by a caller.
<!-- open --> any button, anywhere on the page
<button command="show-modal" commandfor="thing">OPEN</button>
<!-- close --> a button inside or outside the dialog
<button command="close" commandfor="thing">CLOSE</button>
<!-- close --> a submit inside a form that posts to the dialog itself
<form method="dialog"><button>CLOSE</button></form>
<!-- close --> Escape, from the platform, with nothing written downClosing on a click outside
A <dialog> does not close when you click beside it, and there is no attribute that asks it to. The escape hatch is a form that posts to the dialog itself, stretched under the card:
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>It is opt-in by presence, not by a modifier. Include the form and a click outside closes; leave it out — a destructive confirm, a form somebody is halfway through — and it does not. There is no opt-out modifier to get backwards, and no boolean whose false is the dangerous one. The narrow specimen below ships without it, on purpose.
It goes last in the dialog. A full-bleed invisible Close button placed first is what showModal() would hand focus to on open, which is a rotten thing to give somebody. Tab to it and it draws a ring around the page rather than nothing — a control nobody can see is a control nobody can use.
The ladder
The rungs, and the base class is the ordinary one — a modifier only ever changes the width, never grants it. The rungs are in ch rather than pixels because every one of them is a decision about reading, and that stays true when a consumer brings a wider face than ours.
The rungs
<button class="btn btn-quiet" command="show-modal" commandfor="modal-rung-narrow">NARROW</button>
<button class="btn btn-quiet" command="show-modal" commandfor="modal-rung-default">DEFAULT</button>
<button class="btn btn-quiet" command="show-modal" commandfor="modal-rung-wide">WIDE</button>
<button class="btn btn-quiet" command="show-modal" commandfor="modal-rung-full">FULL</button>
<dialog class="modal modal-narrow" id="modal-rung-narrow">
<div class="modal-box">
<div class="modal-body flow">
<p>Narrow. One question and the two answers to it — the rung that must not
grow a paragraph.</p>
</div>
<div class="modal-foot">
<button class="btn btn-quiet" command="close" commandfor="modal-rung-narrow">NO</button>
<button class="btn btn-primary" command="close" commandfor="modal-rung-narrow">YES</button>
</div>
</div>
<!-- No veil form here, deliberately: a confirm is the case where a stray click
outside must NOT answer the question. -->
</dialog>
<dialog class="modal" id="modal-rung-default">
<div class="modal-box">
<div class="modal-head">
<h2 class="modal-title">Default</h2>
<button class="modal-close" command="close" commandfor="modal-rung-default" aria-label="Close"></button>
</div>
<div class="modal-body flow">
<p>The ordinary case, and the one you get by writing no modifier at all: a
measure a paragraph can be read at without the eye losing the start of
the next line.</p>
</div>
</div>
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
</dialog>
<dialog class="modal modal-wide" id="modal-rung-wide">
<div class="modal-box">
<div class="modal-head">
<h2 class="modal-title">Wide</h2>
<button class="modal-close" command="close" commandfor="modal-rung-wide" aria-label="Close"></button>
</div>
<div class="modal-body flow">
<p>For something that is not only prose — a form in two columns, a picture
with its notes beside it, a table that has to stay a table.</p>
<p>Scroll this body if the content outruns the screen. The card stays put;
only the middle moves.</p>
</div>
</div>
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
</dialog>
<dialog class="modal modal-full" id="modal-rung-full">
<div class="modal-box">
<div class="modal-head">
<h2 class="modal-title">Full</h2>
<button class="modal-close" command="close" commandfor="modal-rung-full" aria-label="Close"></button>
</div>
<div class="modal-body flow">
<p>The only rung that is also a height. It fills the screen it was opened
over, less the inset that keeps it reading as a card laid on the page
rather than as a page of its own.</p>
</div>
</div>
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
</dialog>--ds-modal-inset. For media and for a lightbox./* A size the ladder does not have. One property, any rung. */
.hg-brief-modal {
--modal-width: 48ch;
}The slot
.modal-body is where a caller's content goes, and it scrolls when it has to — auto, so a short body shows no dead scrollbar, and overscroll-behavior: contain, so a flick that reaches the end of it does not carry on into the page behind.
Media is the case a scrolling box gets wrong: a tall photograph in a padded body becomes a column you have to scroll through a picture to see. So .modal-media fits it to the box instead — centred, whole, and cropped never, which is the deliberate opposite of frame, whose whole job is to crop to a ratio.
A picture, and a clip
<button class="btn" command="show-modal" commandfor="modal-picture">A PICTURE</button>
<button class="btn" command="show-modal" commandfor="modal-clip">A CLIP</button>
<dialog class="modal modal-full" id="modal-picture">
<div class="modal-box">
<div class="modal-head">
<h2 class="modal-title">Under the bridge</h2>
<button class="modal-close" command="close" commandfor="modal-picture" aria-label="Close"></button>
</div>
<div class="modal-body modal-media">
<img src="/gallery/rico-05.webp" loading="lazy"
alt="A dog stands alone on a wide stony riverbank under the girders of a steel road bridge, big clouds beyond." />
</div>
</div>
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
</dialog>
<dialog class="modal modal-full" id="modal-clip">
<div class="modal-box">
<div class="modal-head">
<h2 class="modal-title">Nose to the lens</h2>
<button class="modal-close" command="close" commandfor="modal-clip" aria-label="Close"></button>
</div>
<div class="modal-body modal-media clip" x-ds-clip>
<video muted loop playsinline preload="none" poster="/gallery/rico-17.avif" controls>
<source src="/gallery/rico-17.webm" type="video/webm" />
<source src="/gallery/rico-17.mp4" type="video/mp4" />
</video>
</div>
</div>
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
</dialog>modal-bare
No card. The picture sits on the veil and nothing frames it — and Escape, the focus trap, the focus return and the veil form are all still the platform's.
<!-- No card: the picture sits on the veil and nothing frames it. Everything
else is the ordinary modal — Escape, the focus trap, the focus return and
the veil form are all still the platform's. -->
<button class="btn" command="show-modal" commandfor="bare-one">OPEN THE PICTURE</button>
<dialog class="modal modal-bare" id="bare-one">
<div class="modal-box">
<div class="modal-head">
<button class="modal-close" command="close" commandfor="bare-one" aria-label="Close"></button>
</div>
<div class="modal-body modal-media">
<img src="/scenery/ink.webp" alt="Coral ink opening in water against pale grey" />
</div>
</div>
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
</dialog>command is valid on a <button> and on nothing else, so an anchor cannot ask a dialog to open. Usually that is fine — make the trigger a button. It is not fine when the trigger has to be a link, because only a link carries an href the browser can hand to its own download machinery. x-ds-after is the one directive for that one case.
A link does its job, and a panel follows
Press either one. The file downloads and the panel opens beside it — nothing is gated, nothing is fetched by script, and with JavaScript off the download still works and no panel appears. The directive goes on the row, not on each anchor.
<!-- The link is a real link and the file is already coming when the panel
opens. Block this page's JavaScript and the download still works; only the
panel is gone. `x-ds-after` sits on the ROW, not on each anchor. -->
<div class="cluster" x-ds-after="after-thanks">
<a class="btn btn-primary" href="/scenery/sprig.webp" download="sprig.webp">DOWNLOAD THE PICTURE</a>
<a class="btn" href="/scenery/ink.webp" download="ink.webp">AND THIS ONE</a>
</div>
<dialog class="modal modal-narrow" id="after-thanks">
<div class="modal-box">
<div class="modal-head">
<h2 class="modal-title">That is downloading</h2>
<button class="modal-close" command="close" commandfor="after-thanks" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Nothing was held back to show you this. The browser took the file the moment you pressed, and this arrived beside it.</p>
</div>
</div>
<form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
</dialog>Reference
Read from components/modal.css when this page was built, so it cannot fall behind the stylesheet.
.modal.modal-box.modal-head.modal-foot.modal-title.modal-body.modal-body-flush.modal-media.modal-narrow.modal-wide.modal-full.modal-bare.modal-close.modal-veil--modal-width — falls back to --ds-modal-width--modal-shadow — falls back to --ds-shadow-overlay--ds-accent--ds-focus-ring--ds-focus-ring-inset--ds-focus-width--ds-font-display--ds-gap--ds-gap-inline--ds-gap-loose--ds-hairline--ds-hit-min--ds-ink--ds-ink-faint--ds-ink-soft--ds-modal-inset--ds-modal-motion--ds-modal-narrow--ds-modal-wide--ds-motion-fast--ds-pad-modal--ds-radius-control--ds-rule--ds-rule-strong--ds-shadow-flat--ds-sunk--ds-text-h3--ds-toggle-glyph--ds-veil--ds-weight-displayNotes
The clip is the existing clip component, unchanged: class="modal-body modal-media clip" puts the fitting and the pause control's positioning context on the same box. It starts when the modal opens and stops when it closes, because x-ds-clip watches whether the video is on screen and a closed dialog is not — measured, not assumed.
A <dialog> is not a blank box. It arrives with a border, a padding, a max-width, an auto margin and — the one that actually bites — background: canvas; color: canvastext, which are the operating system's colours. Left alone, a themed modal renders as a white box with black text on a dark page. Every one of those is taken back.
Measured: a modal dialog stops you interacting with the document behind it and does not stop you scrolling it, so a wheel over the veil moves the article underneath and the reader loses their place. The lock is one rule — html:has(dialog[open]) — and it lives in base.css, because it is a rule about the document and a component may not reach up out of itself to style html.
Animating something to display: none needs allow-discrete — and on overlay as well, which is a property nothing ever sets by hand and can only be named in a transition. Without it the card leaves the top layer on the first frame and the fade plays underneath the page, which reads as a hard cut. The state to animate from is an @starting-style block. Readers who asked for less motion get none of it: base.css collapses every transition globally, and this file deliberately does not spell that out a second time.