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.
Before I build this component, what does the browser already implement, and what exactly breaks when I take it over?
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.
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.
<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.
<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 withshowModal()renders in the top layer, above every stacking context regardless ofz-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 az-indexarms 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. Adivwith label-ish styling does neither. - A
<button>inside a<form>defaults totype="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".
What is actually happening
In the browser, not in the framework.
- Activation behaviour is defined per element in the specification. Dispatching a
clickruns it after the event has finished propagating, unless a listener calledpreventDefault(). Enter and Space on a focused button dispatch thatclickfor you, which is why the correct handler isclickand neverkeydown(preventDefault vs stopPropagation). - Form association is structural. Form-associated elements have a form owner — the nearest ancestor
form, or the one named by theirformattribute — and that owner is what collects values, runs constraint validation and firessubmit. - Constraint validation runs before
submitfires.required,type,pattern,min,maxandstepproduce validity states that drive:invalidand: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
dialogopened modally and for popover-attribute elements. Because it sits outside the normal stacking order, it cannot be trapped inside a parent'stransform,filter,overflow: hiddenorz-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
selectandinput[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
selectpopup 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.
- 1Key event dispatched
keydownfires 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.
- 2Browser synthesises activation
Because the element has an activation behaviour, the browser dispatches a
clickevent — from the keyboard, with no pointer involved.fails by Handling
keydownyourself as well, so the action runs twice; or handling onlykeydownon adiv, where no synthesis happens at all. - 3Click 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. - 4Default action runs
After dispatch completes, the activation behaviour runs: constraint validation, then
submiton the form owner.fails by Calling
preventDefault()after anawait, when this step has already happened (How an Event Is Dispatched). - 5Constraint validation
The browser checks every control, blocks submission if any is invalid, and focuses the first invalid one.
fails by
novalidateadded to silence native bubbles, with no replacement — validation is now entirely gone rather than restyled (Errors People Can Actually Perceive). - 6Submit fires
The
submitevent 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.
semantics <dialog> opened with showModal() — role dialog, modal state exposed to assistive technology, named by aria-labelledby pointing at the visible heading.
| Escape | Closes the dialog and fires cancel then close. No handler needed. |
| Tab / Shift+Tab | Cycles within the dialog only; the rest of the document is inert. |
| Enter in a text field | Implicitly submits the dialog form, because it is a real form with a submit button. |
- — 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.
- — The dialog role and its accessible name on entry.
- — That the rest of the page is unavailable, which is what
inertcommunicates 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.
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
<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.
<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 --><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 forsubmit, and callpreventDefault()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 tokeydown. 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
typeon everybuttoninside a form. The default issubmit, and relying on remembering that is how a "Show password" toggle reloads the page. - Prefer
<dialog>withshowModal()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.
detailsreports its expanded state,dialogreports 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]andinput[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
preventDefault()onsubmitwith 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 callingshowModal(). 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()andclose()handle this; a hand-rolled overlay usually drops focus to the document body (Focus Management). - Intercepting
clickon an anchor for client-side routing without checking for modifier keys, atargetattribute, 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
formactionorformmethodattribute rendered from data, which lets whoever controls that data redirect a form submission to an origin of their choosing. - Calling
preventDefault()after anawait. 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).
- 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
awaitinside aclickhandler ends the synchronous window in whichpreventDefault()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).
- 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
formactionattribute 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).
- "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
keydownso 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. - "
novalidatefixes 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." Asubmitlistener withpreventDefault()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
- 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).
- 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
selectbecomes 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,popoverandinertare 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).
- Native controls are hard to style consistently.
select,input[type=file]andinput[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::backdroprather 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]andinput[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
::backdropbehaviour 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.
- — Runtime Internals — why
preventDefault()after anawaitis not a browser quirk but a direct consequence of where the microtask checkpoint sits relative to the end of event dispatch.