Native Forms First
A <form> ships with submit-on-Enter, validation, autofill, password-manager integration and a label/control relationship. Most custom forms are a worse reimplementation of it.
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.
What does a real <form> element already do, and how much of it am I about to rewrite by accident?
A person wants to give the product some information — an address, a password, a search term — and get on with what they came for. They expect their password manager to fill it, Enter to submit it, and the browser to remember it next time.
Forms are just inputs and a button. Wrap them in a div, attach onClick to the button, collect the values from state, and fetch the result. The form element only adds a default submit behaviour that gets in the way, so leave it out.
Enter in a text field does nothing. Users who type an address and press Enter — which is most users on a keyboard — sit on a screen that did not respond, and try again harder.
- Enter in a text field does nothing. Users who type an address and press Enter — which is most users on a keyboard — sit on a screen that did not respond, and try again harder.
- Password managers do not offer to save the credential, because there is no form to associate a username field with a password field. Support tickets arrive as "your site broke my 1Password", and they are correct.
- Autofill degrades. The browser's heuristics key off form structure,
nameandautocompleteattributes; a baredivof inputs with generated class names gives it nothing to work with (Input Types, Inputmode and Autocomplete). - On a phone the virtual keyboard shows a generic return key instead of Go or Next, because the keyboard label is derived from the form and its remaining fields.
- Screen reader users lose the form landmark and the field-count context ("edit, 2 of 5"), and any implicit label association you thought you had via proximity was never real (The Accessibility Tree).
- The button is a
divtoo, so it is not in the tab order, does not fire on Space, and reports no role. Every one of those is a separate bug you now own (What Native Elements Already Do).
What is actually happening
In the browser, not in the framework.
- A
formelement owns a form owner relationship with every listed control inside it (or associated by theformattribute from outside it). That relationship — not DOM proximity — is what defines "the fields of this form". - Pressing Enter in a text field triggers implicit submission: the browser looks for a default button (the first submit button in tree order) and behaves as if it were activated. With no submit button, a form with a single text field still submits.
- A submit runs the browser's submit algorithm: fire
submit(cancellable), run interactive validation unlessnovalidateis set, construct the entry list from named controls, then either navigate or hand you the event (Submission: Method, Encoding and Doing It Once). <label for="id">(or a wrapping label) creates the accessible name for the control and makes the label a click target that focuses and toggles it. This is a DOM-level relationship the browser exposes on the accessibility tree, not a visual convention.- Password managers and browser autofill treat the form as the unit of detection: a password field plus a preceding identifier field inside the same form is the signal for "credential", and a
submitis the signal for "offer to save it". - The default action of a submit button is submission (preventDefault vs stopPropagation).
preventDefault()in asubmithandler stops the navigation while keeping everything that happened before it — the Enter key, the validation pass, the focus behaviour.
What this makes the browser do
And which of it is avoidable.
- Maintaining the form owner association and the entry list — trivial cost, and work you would otherwise do in JavaScript on every render.
- Running constraint validation over every listed control at submit time, in tree order, stopping at the first invalid one to focus and report it.
- Serialising the entry list for a native navigation submit, or on demand when you construct
new FormData(form). - Deriving accessible names, roles and positional context for assistive technology from the form and its labels. Reimplementing this in ARIA means the browser does the same work from attributes you have to keep correct by hand (Semantics Before ARIA).
- Nothing that scales with form size in a way you can feel. The cost of native forms is not a performance question; the cost of replacing them is.
The same form, twice
The two versions below render identically. The difference is entirely in what the browser is willing to do on your behalf, and none of it shows up in a screenshot.
Read the worse version as sympathetic rather than incompetent: it is what you get when the form is treated as a layout problem and the submit as a button problem. Each individual decision is defensible; the accumulated loss is not.
<div class="form"> <span>Email</span> <input class="i" /> <span>Password</span> <input class="i" type="password" /> <div class="btn" onclick="signIn()">Sign in</div> </div>
<form method="post" action="/session">
<label for="email">Email</label>
<input id="email" name="email" type="email"
autocomplete="username" required />
<label for="pw">Password</label>
<input id="pw" name="password" type="password"
autocomplete="current-password" required />
<button type="submit">Sign in</button>
</form>The second version submits on Enter, is offered to the password manager as a credential, fills from the keychain, shows an email keyboard with a Go key on a phone, announces "Email, edit" instead of "edit, blank", and still works if the bundle fails to load. None of that is styling; all of it is behaviour the first version silently declined.
What a submit actually runs
Attaching onClick to a button replaces one step of a multi-step algorithm and drops the rest. Knowing the steps tells you exactly what you are giving up, and where your own code should hook in.
The important hook is the submit event on the form, which happens after validation and before navigation. That is one line of code, and it keeps every step above it.
- 1Activation
Enter in a text field, a click or keyboard activation of a submit button, or
form.requestSubmit().fails by A
divhandlingclickonly — Enter and keyboard activation never reach it. - 2Interactive validation
Every listed control is checked against its constraints; the first invalid one is focused and its message shown.
fails by
novalidateset without a replacement, so nothing is checked on the client at all (Native Validation and Its Limits). - 3`submit` event
Fires on the form, cancellable. This is where application code belongs.
fails by Handler on
clickinstead — fires before validation and misses two of the three activation paths. - 4Entry list construction
Named, non-disabled controls owned by this form are collected in tree order, including files and multi-value selects.
fails by A control with no
name, or one rendered outside the form withoutform="id"— silently absent. - 5Encode and send
Serialise per
enctypeand navigate with the chosen method, unless the event was cancelled.fails by File input with the default URL encoding, which sends the filename and not the file (Submission: Method, Encoding and Doing It Once).
- 6Post-submit signals
The browser offers to save credentials and updates its autofill profile.
fails by A fully scripted submit with no form element — nothing to detect, nothing offered.
Steps 1, 2, 4 and 6 are free and hard to replace. Step 3 is your entire integration point.
1const form = document.querySelector('form')!2 3form.addEventListener('submit', async (e) => {4 // Validation has already run. We only get here if it passed.5 e.preventDefault() // stop the navigation, keep everything else6 7 const data = new FormData(form)8 await fetch(form.action, { method: 'POST', body: data })9})10 11// A button elsewhere in the UI can still drive the whole algorithm:12form.requestSubmit() // validates, fires submit13// form.submit() // does NOT validate and does NOT fire submitrequestSubmit() and submit() differ in a way that bites: the older submit() skips both validation and your own handler, so a "submit programmatically" refactor can quietly disable every check on the form.
The form as an accessibility contract
A form is one of the few places where the accessible experience and the visual experience are produced by the same markup rather than by parallel implementations. That is worth protecting, because parallel implementations drift.
The spec below is what a plain form gives you for free. Read it as the bar any custom replacement has to clear — not as work to do, but as work already done that you should be careful not to undo.
semantics form (a landmark when it has an accessible name), label bound by for/id, native control roles from the elements themselves, fieldset + legend for grouped controls such as radios.
| Tab / Shift+Tab | Move between controls in DOM order; radio groups are one stop. |
| Enter (in a text field) | Implicit submission via the form's default button. |
| Enter / Space (on the button) | Activate the submit button. |
| Arrow keys (in a radio group) | Move selection within the group, wrapping at the ends. |
| Space (on a checkbox) | Toggle checked state and fire change. |
- — Focus starts wherever the user left it; do not steal it on mount unless the form is the sole purpose of the view.
- — On failed native validation the browser focuses the first invalid control and scrolls it into view.
- — After a successful submit that stays on the page, move focus to the result — a heading or a status region — so keyboard users are not left on a now-irrelevant button (Focus Management).
- — Never remove the focus indicator. Restyle it if the default clashes, but a form you cannot see your position in is a form you cannot fill (Contrast, Colour and Motion).
- — Each control announces its label, its role, its current value and its required state.
- — Screen readers offer a forms mode that lists controls by their accessible names — which is why the name has to be meaningful, not "Field 3".
- — Validation failures and submission results need explicit announcement; the platform does not do this part for you (Errors People Can Actually Perceive).
usually broken by The pattern invites a placeholder used as the label. Placeholder text disappears on focus, is not reliably exposed as an accessible name, fails contrast requirements by default in most designs, and leaves a filled-in field with no visible indication of what it contains.
How to build it
Most important first.
- Start with
<form>, real<label>s, real<input>s withname, and a real<button type="submit">. Add JavaScript to that, rather than building up to it. - Handle
submiton the form, notclickon the button. Thesubmitevent fires for Enter, for the button, and forform.requestSubmit(); the click handler catches one of the three. - Keep
nameattributes even in a fully JavaScript-driven form. They cost nothing, and they are whatFormData, autofill and password managers read. - Give every control a programmatic label. A visible
<label>first;aria-labelonly where a visible label genuinely cannot exist, and never as a substitute for one that could (The Rules of ARIA). - Use
novalidatedeliberately, not reflexively. If you turn native validation off you have taken on the whole job, including the parts that are not styling (Native Validation and Its Limits). - Let the form own the values in the DOM until you have a reason not to. The reason usually arrives — dependent fields, live formatting, cross-field rules — but it should arrive, not be assumed (Controlled vs Uncontrolled Inputs).
- Test the flow with the keyboard alone before you test it with a mouse. A native form passes that test by construction; a rebuilt one rarely does on the first try.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The label/control relationship is the single highest-value thing in this lesson: a control without an accessible name is announced as "edit, blank", which is unusable regardless of how the field looks.
- Native controls come with a role, a value, a state and keyboard behaviour that match the platform conventions of the assistive technology reading them. A rebuilt control matches whatever you remembered to add (Accessible Component Patterns).
- The form landmark and the grouping elements —
fieldsetwith alegendfor a radio group, for example — give screen reader users the shape of the form before they start filling it, which is how they decide whether to fill it at all. - Tab order should follow visual order, which it does automatically in normal flow and stops doing the moment CSS reorders content (Positioning and Stacking Contexts). Positive
tabindexvalues are almost never the answer; ordering the DOM correctly is. - Implicit submission is an accessibility feature. Switch users, voice control users and keyboard users all rely on Enter meaning "do the main thing here", and a form that only responds to a mouse click on a specific
divexcludes all three.
What can go wrong
- A nested
<form>inside another<form>. The parser silently drops the inner one, and the controls you thought belonged to it are owned by the outer form — so they are submitted, validated and serialised with it. - A
<button>withouttypeinside a form defaults totype="submit". A "Show password" or "Add another row" button written that way submits the form every time it is clicked, and the bug presents as a mysterious page reload. preventDefault()in thesubmithandler followed by a thrown error before thefetch. The form neither navigates nor submits, and the user sees nothing at all (Frontend Error Tracking).- Fields rendered outside the
formelement for layout reasons — inside a portal, a modal or a sticky footer — silently drop out of the entry list. Theform="form-id"attribute is the fix, and it is rarely reached for because the symptom is a missing field on the server, not an error in the browser. - Autofill fires without a keystroke. Handlers listening only for
keydownorinput-from-typing miss it, and validation state, enabled/disabled buttons and derived fields go stale (Input Types, Inputmode and Autocomplete). - Re-implementing implicit submission with a global
keydownlistener for Enter. It now also fires insidetextarea, inside a search combobox, and while an IME composition is in progress, submitting half-typed Japanese text.
- Autofill writing several fields in one turn while a render triggered by the first change is still pending, so the later fields are read as empty (Input Types, Inputmode and Autocomplete).
- A submit fired before an async initialisation has finished — a CSRF token fetch, a feature flag, a hydration pass — so the handler runs against a form the application has not finished wiring (Hydration).
- Two activation paths racing on a fast double input: Enter in a field followed immediately by a click on the submit button (Submission: Method, Encoding and Doing It Once).
- Nothing about a native form makes the data trustworthy. The
names, theaction, themethod,required,maxlengthand every disabled attribute are all editable by the user in devtools, so the server must treat the submission as arbitrary input (What the Frontend Is Responsible For in Auth). - A form with an
actionpointing at another origin will happily submit there — forms are not subject to the same-origin policy for sending, only for reading. That asymmetry is the mechanism behind CSRF (Cross-Site Request Forgery). - Disabled and hidden fields are not access control. A hidden
role=admininput is a request the user can change (Authorization-Aware UI). - Password fields inside a form let browsers and password managers apply their own protections — origin-bound credential storage, phishing warnings, breach checks. A fake password field built from a text input with a font trick gets none of them.
- "We use React, so the form element does not matter." React renders a real
formto a real DOM; every browser behaviour described here applies unchanged. What React changes is where the value lives, not what the element does. - "We prevent the default anyway, so the element is decorative." Preventing the default stops the navigation. It does not stop implicit submission, validation, autofill, password managers or the accessibility tree — those are exactly what you keep.
- "Native means no JavaScript." Native means starting from the element and adding behaviour, not refusing behaviour.
- "A click handler on the button is equivalent." It misses Enter, misses
requestSubmit(), and fires for a keyboard Space on the button only because the button is a button — the very thing being replaced. - "Accessibility is the reason to use forms." It is one reason. Autofill, password managers, mobile keyboards and Enter-to-submit are conversion features that product managers care about independently.
Measuring it, and what changes in the field
- The Elements panel, reading the actual tree: is the control inside the form, does it have a
name, does the label'sformatch an existingid? - The Accessibility pane on a selected input — computed name, role and the source the name came from. If the name is empty, the label is not doing what you think.
- Keyboard-only traversal of the whole flow, which finds missing tab stops, dead Enter keys and focus that escapes into the page background faster than any tooling (Accessibility Testing).
- The Network panel on submit: a native submit is a document navigation, a scripted one is an XHR/fetch entry. Seeing the wrong one tells you a handler did or did not run.
- On mobile, the keyboard type, the return key label, autofill suggestions and password manager integration all key off form structure. The gap between native and rebuilt forms is much wider on a phone than on a laptop.
- With a password manager or an enterprise SSO extension installed, fields are filled programmatically and out of band. Any assumption that values only change through user typing is wrong for a meaningful slice of users.
- On a slow device, a native form is interactive as soon as the HTML parses; a JavaScript-driven one is not interactive until its bundle has loaded, parsed and hydrated (Hydration).
- With JavaScript failing to load at all — a bad deploy, a blocked CDN, a strict extension — a native form with a server-side
actionstill works. That is the whole argument for progressive enhancement, stated as a failure mode rather than a philosophy.
- Native controls are harder to style. Some parts of
select, date and file inputs are still not fully styleable across engines, and matching a design system exactly may genuinely require a custom control — which then owes the full keyboard and ARIA contract. - Native validation messages are browser-worded, browser-styled and browser-positioned, and they are not localisable to your product's voice (Native Validation and Its Limits).
- Progressive enhancement — a form that works without JavaScript and better with it — requires a server endpoint that accepts a normal form post, which is real backend work you might not otherwise need (POST: More Than Create).
- Sticking to the platform sometimes means arguing with a design that was drawn without knowing what a native control does. That argument is cheaper than the year of accessibility bugs on the other side of it.
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.
- GENERALForm owner association, implicit submission, the submit algorithm and label semantics are specified in HTML and behave the same in Blink, Gecko and WebKit. What differs is presentation, not behaviour.
- PLATFORM-SPECIFICAutofill and password-manager integration are browser and OS features, not spec: Safari on iOS uses the keychain and its own field heuristics, Chrome uses its own profile data plus
autocompletetokens, and third-party managers inject their own overlays — so the same markup gets different fill behaviour per platform.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — "start from the primitive the platform already gives you, then extend" is a general design instinct; forms are the place a frontend engineer learns it most cheaply.