HTMLGENERALBROWSER-SPECIFICSPEC-EVOLVING

What Native Elements Already Do

Activation behaviour, form participation, implicit submission, the top layer and constraint validation — the platform features most component libraries reimplement, more slowly and less completely.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Before I build this component, what does the browser already implement, and what exactly breaks when I take it over?

The user intent

A person wants to submit a form, open a dialog, expand a section or dismiss an overlay — using the conventions every other application on their device already taught them.

The obvious build

Native elements are limited and hard to style. Build the widget from divs, wire the behaviour up with handlers, and it will match the design exactly with no fighting.

Why it breaks

<details> and <summary> give you open and closed state, keyboard toggling, correct announcement of the expanded state, and find-in-page opening the disclosure to reveal a match inside it. The div version has none of that, and the find-in-page behaviour in particular is a feature nobody thinks to reimplement.

How it breaks in a real browser
  • <details> and <summary> give you open and closed state, keyboard toggling, correct announcement of the expanded state, and find-in-page opening the disclosure to reveal a match inside it. The div version has none of that, and the find-in-page behaviour in particular is a feature nobody thinks to reimplement.
  • <dialog> opened with showModal() renders in the top layer, above every stacking context regardless of z-index, makes the rest of the document inert, gives you ::backdrop, closes on Escape, and returns focus on close. A div-based modal gets none of those and typically starts a z-index arms race instead (Positioning and Stacking Contexts).
  • Implicit submission — pressing Enter in a text field submits the form — only exists when there is a real <form> with a submit button. Remove the form and you have removed a behaviour every user on earth has been trained on.
  • Input types decide the mobile keyboard, the autofill behaviour and the native picker. type="email" gives a keyboard with an @ key; type="text" with a regex gives a worse experience on every phone (Input Types, Inputmode and Autocomplete).
  • Clicking a <label> focuses or toggles its control, and enlarges the hit target for anyone with imprecise pointing. A div with label-ish styling does neither.
  • A <button> inside a <form> defaults to type="submit". This single default is behind an enormous share of "why did the page reload" bugs — and its absence in a div-based form is behind an equal share of "why does Enter do nothing".
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Activation behaviour is defined per element in the specification. Dispatching a click runs it after the event has finished propagating, unless a listener called preventDefault(). Enter and Space on a focused button dispatch that click for you, which is why the correct handler is click and never keydown (preventDefault vs stopPropagation).
  • Form association is structural. Form-associated elements have a form owner — the nearest ancestor form, or the one named by their form attribute — and that owner is what collects values, runs constraint validation and fires submit.
  • Constraint validation runs before submit fires. required, type, pattern, min, max and step produce validity states that drive :invalid and :user-invalid, and the browser blocks submission and focuses the first invalid control on its own (Native Validation and Its Limits).
  • The top layer is a separate rendering surface for dialog opened modally and for popover-attribute elements. Because it sits outside the normal stacking order, it cannot be trapped inside a parent's transform, filter, overflow: hidden or z-index — the four things that break every hand-built overlay.
  • Native controls have an internal shadow tree and, on some platforms, a native-drawn appearance. That is why select and input[type=date] resist styling: you are not styling one element, you are styling a widget the platform owns (Shadow DOM and the Composed Tree).
  • All of this is implemented before your bundle exists. A <details> toggles while the page is still parsing; a JavaScript disclosure is inert until its script has downloaded, parsed and run (Hydration).

What this makes the browser do

And which of it is avoidable.

  • The top layer avoids a stacking-context restructure. A hand-built modal typically promotes a large subtree, creates a new stacking context, and often a new compositing layer for the backdrop (Compositing Layers).
  • A native select popup on most platforms renders outside the page entirely — no page layout, no paint, no main-thread scrolling. A custom listbox scrolls on the main thread and lays out every option (The Frame Budget).
  • Constraint validation runs in the engine over the form's controls. The JavaScript equivalent runs a validation library over a state object, usually on every keystroke (Controlled vs Uncontrolled Inputs).
  • The avoidable work is the whole reimplementation: bytes downloaded, parsed, compiled and executed to reproduce behaviour that was already present and already correct (The Real Cost of JavaScript).

From key press to default action

The reason the correct handler is click and not keydown is not style. It is that the browser performs the keyboard-to-activation translation itself, per element, with the platform's conventions baked in — and then runs the element's activation behaviour afterwards, in a step that your listener can cancel but cannot reproduce.

The sequence below is what runs for a single Enter press on a focused submit button. Every stage is a place where an intervention can go wrong, and the failsBy column is a fair summary of the bug reports this module exists to prevent.

Enter on a focused submit button
  1. 1
    Key event dispatched

    keydown fires on the focused element and bubbles. Everything registered along the path sees it.

    fails by A global key handler cancels the event for its own shortcut and silently disables every button on the page.

  2. 2
    Browser synthesises activation

    Because the element has an activation behaviour, the browser dispatches a click event — from the keyboard, with no pointer involved.

    fails by Handling keydown yourself as well, so the action runs twice; or handling only keydown on a div, where no synthesis happens at all.

  3. 3
    Click propagates

    Capture down the tree, fire at the target, bubble back up. Delegated listeners on ancestors run here (Event Delegation).

    fails by A delegated handler calling stopPropagation(), which cancels other listeners but not the default action, so half the behaviour survives.

  4. 4
    Default action runs

    After dispatch completes, the activation behaviour runs: constraint validation, then submit on the form owner.

    fails by Calling preventDefault() after an await, when this step has already happened (How an Event Is Dispatched).

  5. 5
    Constraint validation

    The browser checks every control, blocks submission if any is invalid, and focuses the first invalid one.

    fails by novalidate added to silence native bubbles, with no replacement — validation is now entirely gone rather than restyled (Errors People Can Actually Perceive).

  6. 6
    Submit fires

    The submit event fires on the form. This is the one place an application should intervene.

    fails by preventDefault() with a handler that throws, leaving no submission path at all.

A div skips straight from step one to nothing. That is the entire difference, and it is five behaviours deep.

The dialog you did not have to build

Modal dialogs are the clearest case in the module because the correct implementation is short and the incorrect one is a well-known multi-hundred-line component. The top layer removes the z-index problem structurally rather than by escalation, and showModal() supplies the inertness that a focus trap only approximates.

The one behaviour still worth writing yourself is what happens on cancel — close() fires a close event with returnValue, which is the hook for treating dismissal and confirmation differently. That is a genuine application concern, unlike the other seven things the component library was doing.

accessibility specModal dialogWhat `showModal()` gives you, and what it does not

semantics <dialog> opened with showModal() — role dialog, modal state exposed to assistive technology, named by aria-labelledby pointing at the visible heading.

EscapeCloses the dialog and fires cancel then close. No handler needed.
Tab / Shift+TabCycles within the dialog only; the rest of the document is inert.
Enter in a text fieldImplicitly submits the dialog form, because it is a real form with a submit button.
Focus
  • On open, focus moves into the dialog — to the first autofocusable element, or the dialog itself.
  • On close, focus returns to the element that had it before opening.
  • Content behind the dialog is inert: not focusable, not reachable by the reading cursor, not clickable.
Announces
  • The dialog role and its accessible name on entry.
  • That the rest of the page is unavailable, which is what inert communicates and a visual overlay does not.
  • Validation messages from constraint validation, focused automatically on the first invalid control.

usually broken by Using the open attribute instead of showModal(). The dialog appears, and it is a non-modal element in the normal flow: no top layer, no inertness, no Escape, no focus return. It looks identical in a screenshot and is broken in every way that matters.

A modal, with the platform doing the work
1<button type="button" id="open">Edit profile</button>
2
3<dialog id="dlg" aria-labelledby="dlg-title">
4 <form method="dialog">
5 <h2 id="dlg-title">Edit profile</h2>
6 <label for="name">Display name</label>
7 <input id="name" name="name" required autocomplete="name">
8 <button value="cancel" formnovalidate>Cancel</button>
9 <button value="save">Save</button>
10 </form>
11</dialog>
12
13<script>
14 const dlg = document.getElementById('dlg')
15 document.getElementById('open').addEventListener('click', () => dlg.showModal())
16 dlg.addEventListener('close', () => {
17 if (dlg.returnValue === 'save') { /* the only application concern here */ }
18 })
19</script>

method="dialog" closes the dialog on submit and sets returnValue to the activating button's value. formnovalidate on Cancel is what stops the required field from blocking a dismissal. Not written: the focus trap, the focus return, the backdrop, the Escape handler, the inertness of the page behind, and the stacking context.

Two disclosures

BROWSER-SPECIFICFind-in-page opening a closed <details> and expansion of collapsed content when printing are behaviours that arrived at different times in Chromium, Firefox and Safari and are still not identical across them. The state, keyboard and announcement behaviour is consistent everywhere; the auto-expansion niceties are the part to verify.

The disclosure is the most-reimplemented widget on the web, and the comparison is unusually stark because the native version is one element and has one behaviour the JavaScript version essentially never reproduces: find-in-page opens it. A user searching the page for a term buried inside a collapsed section finds it with <details> and does not with a div.

The because here is worth stating plainly, since "use the native element" can sound like taste. It is not taste: the div version is inert until hydration, silent about its state unless aria-expanded is maintained correctly on every path, and invisible to find-in-page permanently.

A collapsible section
Hand-built
<div class="acc">
  <div class="acc-head" onclick="toggle()">Shipping details</div>
  <div class="acc-body" style="display:none">...</div>
</div>
<!-- plus: tabindex, role="button", aria-expanded,
     aria-controls, key handling, and state kept in sync -->
Native
<details>
  <summary>Shipping details</summary>
  ...
</details>

The native version is operable during parsing, before any script runs; announces its expanded state without an attribute you have to remember to update; is toggled by the platform keyboard convention; and is opened automatically by find-in-page when a match is inside it. The hand-built version can be brought to parity on the first three with care and cannot reach the fourth at all.

How to build it

Most important first.

  • Start from the native element and restyle it. appearance, accent-color, and pseudo-element hooks cover more than they used to, and a restyled native control keeps its behaviour where a replacement does not.
  • When the native element genuinely cannot be shaped — a combobox with rich options, a multi-select with tokens — wrap it rather than replace it where you can, and accept that you now own a keyboard specification (Accessible Component Patterns).
  • Keep the <form> even in a single-page application. Render a real form with a real submit button, listen for submit, and call preventDefault() there. You keep implicit submission, autofill, password-manager integration and constraint validation for the cost of one handler (Native Forms First).
  • Attach behaviour to click, not to keydown. The browser has already translated the keyboard into a click for every element where that is correct, and doing it yourself gets Space-versus-Enter and key-up-versus-key-down subtly wrong.
  • Set type on every button inside a form. The default is submit, and relying on remembering that is how a "Show password" toggle reloads the page.
  • Prefer <dialog> with showModal() and the popover attribute where support allows, with a tested baseline for engines that lack them. These are the two APIs that removed the most hand-written code from real codebases in recent years.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Native elements expose role and state without ARIA. details reports its expanded state, dialog reports that it is modal, a checkbox reports checked and mixed. A hand-built version must set and, crucially, keep updating each of those.
  • Keyboard conventions come from the platform, not from your design system. Escape closes, Tab cycles inside a modal, arrows move within a composite, Home and End jump. Users bring these expectations from their operating system.
  • showModal() makes the rest of the document inert for assistive technology as well as for the pointer, which is the part a visual focus trap does not do.
  • Native controls respond to forced-colors, increased contrast and reduced motion because the platform draws them. A custom control renders whatever you specified, including through settings the user chose deliberately (Contrast, Colour and Motion).
  • Native is not automatically perfect: select[multiple], input[type=date] and input[type=range] are announced and operated differently across platforms and assistive technologies, and are worth testing rather than assuming. The point is that native is a better starting position, not a finished one.

What can go wrong

Failure modes
  • preventDefault() on submit with no alternative path, so the form silently does nothing when the JavaScript that was supposed to handle it throws.
  • <dialog open> used as an attribute instead of calling showModal(). It renders, it is not in the top layer, the background is not inert, and Escape does nothing — a dialog that looks right and behaves like a div.
  • Focus not returned to the element that opened the dialog. showModal() and close() handle this; a hand-rolled overlay usually drops focus to the document body (Focus Management).
  • Intercepting click on an anchor for client-side routing without checking for modifier keys, a target attribute, or a non-primary button. Cmd-click now navigates in place instead of opening a tab.
  • Custom Escape handling stacked on top of a native dialog, so one press closes two things.
  • A formaction or formmethod attribute rendered from data, which lets whoever controls that data redirect a form submission to an origin of their choosing.
  • Calling preventDefault() after an await. The event has already finished dispatching by then; the default action ran, and the code reads as though it did not (How an Event Is Dispatched).
  • preventDefault() inside a listener registered on a passive-by-default event, where it is ignored and warns in the console rather than doing anything (Passive Listeners).
What can arrive out of order
  • Implicit submission racing async validation: the user presses Enter while a debounced availability check is still in flight, and the form submits against a validity state that has not been updated yet.
  • An await inside a click handler ends the synchronous window in which preventDefault() has any effect. The default action has already happened by the time the promise resolves (How an Event Is Dispatched).
  • A <dialog> closed by Escape while an in-flight request that will populate it is still pending. The response arrives for a dialog that is no longer open, and naive code reopens it (Cancelling a Request Nobody Is Waiting For).
Security
  • A <form> posts cross-origin with cookies attached, without any script and without CORS being involved. That default is the entire mechanism behind cross-site request forgery, and it is why the defence must be a token or a cookie attribute rather than anything in the markup (Cross-Site Request Forgery).
  • A formaction attribute on a submit button overrides the form's action. If any attribute on a form control is rendered from untrusted data, the submission target is attacker-controlled (Cross-Site Scripting).
  • <dialog> in the top layer protects nothing against your page being framed by someone else. Clickjacking is a framing problem and is answered by a response header, not by an element (Clickjacking and Framing).
  • autocomplete="off" is a hint, honoured inconsistently and increasingly ignored for password fields because ignoring it produces better security outcomes. It is not a control and must never be used as one.
  • Native constraint validation runs in the browser, which the user controls entirely. It is a usability feature. Every rule it expresses must also exist on the server (The Three Validations in Backend Engineering).
Misreads
  • "Native elements cannot be styled." Most can be, and the ones that resist are a short, known list. "Hard to style" is a reason to check what is possible now, not a reason to rebuild the behaviour.
  • "A focus trap makes a modal accessible." It constrains Tab. showModal() additionally makes the rest of the document inert, which is what stops a screen-reader cursor reading straight past the dialog (Focus Management).
  • "Handle keydown so keyboard users can activate it." The browser already turns Enter and Space into a click on elements that have an activation behaviour. Adding your own handler either duplicates the action or subtly contradicts the platform convention.
  • "novalidate fixes the ugly validation bubbles." It removes validation entirely. Styling the messages means keeping constraint validation and rendering the messages yourself from the validity state (Errors People Can Actually Perceive).
  • "<form> is a page-reload thing." A submit listener with preventDefault() gives you the whole platform form behaviour and full control of what happens next (Native Forms First).

Measuring it, and what changes in the field

How you would see this
  • Enable "Show user agent shadow DOM" in devtools settings to see what a native control is actually made of before deciding it cannot be styled.
  • The Event Listeners pane on a selected element shows every handler and which ancestor registered it — the fastest way to find the delegated listener that is cancelling your default action (Event Delegation).
  • Disable JavaScript and reload. Whatever still works is what the platform was doing for you, and it is usually more than the team expects.
  • Coverage and bundle analysis will show the weight of a component library's dialog, menu and disclosure implementations, which is the number the trade-off actually turns on (Bundle Analysis).
Slow device, slow network, large data, old tab
  • Before hydration, native behaviour works and custom behaviour does not. On a slow device that window is long enough for a real user to click, get nothing, and click again (Hydration).
  • When a bundle fails to load — a bad deploy, a blocked CDN, a flaky network — a form built on <form> still submits and a form built on handlers is a dead page (Frontend Error Tracking).
  • On mobile, a native select becomes an operating-system picker and a native date input becomes a platform date wheel. A custom equivalent is a small scrolling div on a touch screen.
  • dialog, popover and inert are relatively recent. On older engines and inside embedded webviews, availability varies, so this is a feature-detection decision rather than an assumption (Polyfills vs Transpilation).
What this costs
  • Native controls are hard to style consistently. select, input[type=file] and input[type=date] are drawn by the platform, and a design that specifies them pixel-exactly is specifying something the platform does not offer.
  • Restyling <dialog> means working with the top layer and ::backdrop rather than with your existing overlay conventions, which is a real learning cost for a team that already owns a modal component.
  • Progressive enhancement — building on a real form that works before JavaScript — costs a server endpoint that can accept the submission, which some single-page architectures do not have and would have to add (Submission: Method, Encoding and Doing It Once).
  • Keeping the native element and layering behaviour on top means living with behaviour you did not choose. Occasionally the platform does something the product genuinely does not want, and the honest answer is a custom widget with a written keyboard specification.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALActivation behaviour, form association, implicit submission and constraint validation are HTML specification behaviour, implemented consistently across Blink, Gecko and WebKit. The differences are in appearance, not in the model.
  • BROWSER-SPECIFICThe appearance and the internal shadow structure of native controls are engine and platform decisions: select, input[type=date] and input[type=file] are drawn differently in Chromium, Firefox and Safari, and differently again on iOS and Android. Never ship a design that depends on a control looking a specific way.
  • SPEC-EVOLVINGThe top layer surface is still growing — the popover attribute, anchor positioning and ::backdrop behaviour have all changed recently and support differs by engine and version. Feature-detect, and re-check availability rather than trusting a tutorial from two years ago.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Concurrencyui-concurrency
Domains that do not exist yet
  • Runtime Internals — why preventDefault() after an await is not a browser quirk but a direct consequence of where the microtask checkpoint sits relative to the end of event dispatch.