Skip to content

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

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

NOT ONE LINE OF SCRIPT, AND THAT IS THE WHOLE DESIGN

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 down

Closing 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

HTML
<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>
.modal-narrowA confirm. One question and the two answers to it.
.modalNo modifier. A paragraph and a control or two.
.modal-wideProse beside media, a form in two columns, a table.
.modal-fullThe only rung that is also a height — it fills the screen, less --ds-modal-inset. For media and for a lightbox.
--modal-widthThe one public property. Set it on any rung for a size the ladder does not have.
/* 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

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

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

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

CLASSES.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
SETTINGSSet the first on one element; a theme sets the second for all of them.--modal-width — falls back to --ds-modal-width--modal-shadow — falls back to --ds-shadow-overlay
READS--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-display

Notes

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.

WHAT THE UA STYLESHEET DOES, AND WHY THIS FILE IS LONGER THAN IT LOOKS

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.

THE PAGE BEHIND IS INERT BUT NOT FROZEN

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.

IT FADES OUT AS WELL AS IN, WHICH TAKES TWO PROPERTIES NOBODY EXPECTS

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.