← All writing
Craft · · 7 min

A modal is a form

On the modal I kept rebuilding, the form integration nobody uses, and the one behavior that is still yours to write.

HTML Accessibility

And it has been one the whole time I’ve been carefully building it out of something else.

In April I wrote about the popover attribute and said that a popover is not a modal, that there’s a separate element for the modal case, and that I’d write that one up because it has its own set of things people get wrong. This is that post. It didn’t go the way I planned it.

Because last October I wrote about inert finally arriving in all 3 engines, and how one attribute replaced the 40-line focus trap I’d hand-written on 4 different projects, and I was so pleased with myself. Then I sat down to research this one, the way you do when you’re about to explain something to other people, and read the <dialog> documentation properly for the first time. showModal() has been doing the inert part since 2022, along with 4 other things I was still doing by hand around it 🤦🏼‍♀️

What the method call is doing

The whole thing, and I’d look at how much of it is markup.

confirm.html
<button id="delete-account">Delete account</button>

<dialog id="confirm-delete">
  <h2>Delete this account?</h2>
  <p>This removes all their orders. It can't be undone.</p>
  <form method="dialog">
    <button value="cancel" autofocus>Keep it</button>
    <button value="delete" class="danger">Delete</button>
  </form>
</dialog>

<script>
  const dialog = document.getElementById('confirm-delete');

  document.getElementById('delete-account')
    .addEventListener('click', () => dialog.showModal());

  dialog.addEventListener('close', () => {
    if (dialog.returnValue === 'delete') deleteTheAccount();
  });
</script>
HTML

It renders in the top layer, which is a separate surface above the entire document, so the stacking bugs simply aren’t available to you anymore. Everything outside it goes inert, unfocusable and unclickable and gone from the accessibility tree. Focus moves in on open and goes back to the button that opened it on close, which is the line everybody forgets, me four times. Escape closes it. There’s a ::backdrop pseudo-element for the dim, so the overlay isn’t a div you position either.

Note the autofocus on the harmless button. Focus lands on the first focusable thing unless you say otherwise, and on a destructive confirmation that means somebody hitting Enter twice in a row deletes an account they were only looking at. Put it on the one that does nothing.

Is that your final answer

The part that reorganized how I think about the whole component. It’s been sitting in the spec since before I built any of mine.

<form method="dialog"> doesn’t submit anywhere. It closes the dialog it’s inside and sets returnValue to the value of whichever button was pressed.

So the entire “which button did they press” plumbing is markup. No click handler per button, no confirmed variable, no callback threaded through three files to carry an answer back to the thing that asked the question. One close listener that reads a string.

Which is what I mean about it being a form. A confirmation is a question with two possible answers. That’s what a form has always been: a set of answers, plus a value saying which one you gave. I used <dialog> for about two years before I noticed, because every example I have ever read, mine very much included, hangs a click handler off each button and manages the answer in JavaScript.

The click that lands on nothing

The one behavior still yours to write. Clicking outside a modal doesn’t close it, which is arguably correct for something demanding a decision and is not at all what people expect from anything lighter.

The implementation is a nice piece of trivia. The backdrop isn’t an element you can listen on, but a click on it targets the <dialog> itself, where a click on your content targets your content.

backdrop.js
dialog.addEventListener('click', (event) => {
  // only true when the click landed on the backdrop
  if (event.target === dialog) dialog.close();
});
JavaScript

That only holds if the dialog’s own padding is 0 and your content sits in a wrapper inside it. Leave the padding on and the ring around your content still counts as the dialog, so closing gets persnickety about exactly where somebody clicked, in a way that reads as random.

The gotchas

Don’t put open in the markup. It does show the dialog. It shows it non-modally, which means no top layer, no backdrop, no inert page and no focus handling. It looks for all the world like the element is broken. Open it with showModal().

Don’t override display, same as the popover. The browser’s own styles use display: none while it’s closed, so a display: flex on the dialog element makes it permanently visible. Layout goes on a wrapper inside.

Escape fires cancel before it fires close, and cancel is preventable, which is how you build “you have unsaved changes” without taking ownership of the Escape key yourself.

Go and check the background scrolling. Browsers have been inconsistent here and plenty of teams still lock the body while a dialog is open. I’d test it instead of assuming, particularly on iOS.

Animating it wants the same machinery as the popover and the story is the same one: a fade in is easy, a fade out is fiddly, and on client work this year I generally don’t animate the close at all.

Are you sure?

The question worth asking before any of the above, and the one I keep forgetting to ask.

Most confirmation dialogs shouldn’t exist. “Are you sure?” interrupts everybody to catch the small number of people who made a mistake, and after a few of them people have stopped reading and started clicking through on reflex. So it has caught its last mistake and is now pure friction with a drop shadow under it.

Undo is nearly always better. Do the thing, say what you did, offer to reverse it for 30 seconds. It’s faster for everybody who meant it, it genuinely rescues the ones who didn’t, and it doesn’t train anyone to dismiss warnings.

I know, I know, sometimes there’s nothing to undo. Sending an email, taking a payment, anything with a side effect out in the world. That’s where a confirmation earns its interruption. That’s where the copy is the whole design. “Are you sure?” with OK and Cancel tells nobody anything. “Delete this account? This removes all their orders and can’t be undone,” with a button that says Delete, is a decision somebody can actually make.

Why I never went looking

The element has been in Chrome since 2014 and in all 3 engines since the spring of 2022. I built 2 of those 4 modals after that, and the more recent one was 300 lines of my own JavaScript reimplementing a method call, spread over enough bug reports that I can’t honestly add up the hours.

Some of that is a skill issue and I’ll own it. But I can’t help but turn over that each one went faster than the last, and going faster felt like getting better at my job, when it was actually fluency at a thing that had stopped needing to be done. So the feeling I now treat as a warning is the comfortable one. If I can write something from memory without opening a tab, that’s the thing I should go and read the docs for, which is how this post ended up happening about two years later than it would have been useful.

Read similar posts
9 min

h4 was the right size

I pulled up the list of headings on a client's page and got the company name and three things at level four, with the actual subject of the page missing from the list entirely because somebody had built it out of a div.

7 min

Printing an accordion

A customer saved a client's returns page as a PDF and got back a column of questions with white space under every one of them, which is what an accordion does when it meets a printer.