Step 2 — Native <dialog> vs Your Modal
Goal
Test modals behaviourally rather than visually, and price what a custom one actually costs.
Predict first
Three modals, same appearance: native <dialog> + showModal(); a naive custom
<div role="dialog" aria-modal="true">; and a careful custom one with focus trap, inert, Escape
and focus restore.
For each, predict pass/fail on: focus moves in · focus trapped over 8 Tabs · background inert ·
Escape closes · focus restored to trigger · ::backdrop available.
Then predict JS bytes for each. Most people expect the naive custom modal to be the smallest.
Run
npm run dialog
Expected output:
behaviour | <dialog> showModal()| custom, naive | custom, careful
opens | yes | yes | yes
focus moves in | yes | NO | yes
focus trapped | yes | NO | yes
background inert | yes | NO | yes
Escape closes | yes | NO | yes
focus restored | yes | NO | yes
::backdrop | yes | NO | NO
JS required (normalised bytes):
<dialog> showModal() 222 bytes (1.0x native)
custom, naive 241 bytes (1.1x native)
custom, careful 1200 bytes (5.4x native)
What just happened
The naive custom modal passes 1 of 7 — and costs more bytes than native. It is exactly what
"just use a div with role="dialog" and aria-modal="true"" produces. aria-modal is a promise
to assistive technology that the rest of the page is unavailable; it does not make it so. Focus
walks straight out the back.
The careful version reaches 6/7 at 5.4× the code. It is a genuine good-faith implementation —
focus trap with wrap-around, inert on siblings, Escape handling, focus restore. It is also now
yours forever: it must keep working as new focusable element types appear, across shadow DOM
boundaries, inside iframes, and through browser focus-behaviour changes.
Note how the tests are written. Not "is there a focus trap in the code" but: press Tab eight times through CDP and ask where focus is. Press Escape and ask whether it closed. Try to focus a background button and see whether the browser allows it. Behaviour is the requirement, so behaviour is what is measured — and this is the test suite you should demand of any component library before adopting it.
The one native gap is ::backdrop, available only to modal <dialog>. Which points at the real
trade: the objection to native elements is almost always styling, and the platform has been
closing that gap (::backdrop, Popover API, anchor positioning, appearance: base-select). See
docs/analysis.md.
The decision, stated properly
You are not choosing markup. You are choosing an ownership boundary. The question is not "can we build this?" — you can — but "will it still be correct in three years, after four team changes, a framework upgrade, and a browser focus-behaviour change?"
Measured cost for the simplest possible case: 5.4× the code and still one behaviour short. A combobox is an order of magnitude beyond that.
Checkpoint
docs/verification.md Checkpoint 3.