Concept
Forms are the most interactive part of most web apps, and HTML gives you an enormous amount of behavior for free, input types, native validation, keyboard handling, before a single line of JavaScript. The most common mistake senior-looking codebases still make is rebuilding all of that in JS because nobody on the team learned what the platform already does.
Every input needs a <label>
<!-- Wrong: placeholder is not a label -->
<input type="email" placeholder="Email address" />
<!-- Right: explicit label, associated by id -->
<label for="email">Email address</label>
<input id="email" type="email" name="email" />
<!-- Also valid: implicit association by wrapping -->
<label>
Email address
<input type="email" name="email" />
</label>placeholder text disappears the moment the user starts typing, often has poor contrast, and isn't reliably read as a label by all assistive technology. A <label> is: always visible, clickable (clicking it focuses/activates the input), and is what screen readers announce as the field's accessible name.
Input types are not cosmetic
<input type="email" /> <!-- mobile keyboards show @ and .com shortcuts -->
<input type="tel" /> <!-- numeric keypad on mobile -->
<input type="number" /> <!-- spinner controls, numeric keypad, range constraints -->
<input type="url" /> <!-- validates URL shape, mobile keyboard has / and .com -->
<input type="date" /> <!-- native date picker, locale-aware -->
<input type="search" /> <!-- adds a clear (×) button on most browsers -->
<input type="password" /> <!-- masks input, offers password manager integration -->Each type changes the mobile keyboard layout, triggers built-in validation, and can enable browser autofill and password manager suggestions. Using type="text" for everything throws away all of it.
Native validation attributes
<input type="email" required />
<input type="text" minlength="3" maxlength="20" required />
<input type="number" min="1" max="100" step="1" />
<input type="text" pattern="[A-Za-z]{3,}" title="At least 3 letters" />These constraints run automatically when the form is submitted, no JS required. The browser blocks submission and shows a native validation bubble pointing at the offending field. title supplies the message shown for pattern mismatches.
The Constraint Validation API
For custom validation UI (native bubbles are functional but not very stylable), use the JS API rather than reimplementing validation logic from scratch:
const input = document.querySelector("#email");
input.addEventListener("invalid", (e) => {
e.preventDefault(); // suppress the native bubble
showCustomError(input.validationMessage);
});
// Check validity programmatically
if (input.checkValidity()) {
// passes all constraints
}
// Inspect *why* it failed
console.log(input.validity);
// ValidityState { valueMissing, typeMismatch, patternMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow, stepMismatch, ... }
// Set a custom error message (e.g. from an async check)
input.setCustomValidity(isUsernameTaken ? "Username is already taken" : "");input.validity is a ValidityState object, every property is a boolean telling you exactly which constraint failed, which is far more precise than parsing a regex yourself.
Form submission without a page reload
<form id="signup">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<button type="submit">Sign up</button>
</form>document.querySelector("#signup").addEventListener("submit", async (e) => {
e.preventDefault(); // stop the native full-page GET/POST navigation
const formData = new FormData(e.target);
await fetch("/api/signup", { method: "POST", body: formData });
});FormData automatically collects every named field, including file inputs, without manually reading each .value. This is also what makes <form> (rather than a <div> of inputs with a JS click handler) valuable even in a fully JS-driven app: you get Enter-to-submit for free, and FormData for free.
Grouping related fields: <fieldset> and <legend>
<fieldset>
<legend>Shipping address</legend>
<label for="street">Street</label>
<input id="street" name="street" />
<label for="city">City</label>
<input id="city" name="city" />
</fieldset>
<fieldset>
<legend>Payment method</legend>
<label><input type="radio" name=
<legend> is announced by screen readers before every field inside the group, critical for radio button groups where "Card" and "PayPal" alone are meaningless without knowing the question being answered.
Common Mistakes
1. placeholder instead of <label>
Covered above, the single most common forms accessibility bug. Test: if you can't tell what a field is for once it has text in it and you've scrolled the label out of view, it's broken.
2. Disabling the submit button until the form is "valid" via JS, duplicating native validation
// Wrong: reimplementing what `required` + `type="email"` already do
button.disabled = !email.includes("@") || password.length < 8;This blocks keyboard submission (Enter), doesn't tell the user why the button is disabled, and duplicates logic the browser already runs. Prefer letting the form submit and be blocked/messaged by native (or Constraint Validation API) validation.
3. Using <div> + onclick for radio/checkbox groups
Custom-styled "toggle" UIs frequently throw away the actual <input type="radio">/<checkbox> and rebuild toggling in JS with <div>s. This loses native form submission (FormData won't include it), native keyboard behavior (arrow keys move between radios in a group automatically), and screen reader semantics. Style the real input (opacity: 0 + a sibling visual element, or the appearance CSS property) instead of replacing it.
4. Forgetting name attributes
<!-- Wrong: has an id but no name -->
<input id="email" type="email" />FormData and traditional form submission both key off name, not id. A field with only an id silently vanishes from submitted data.
5. Client-side-only validation with no server-side check
Native/JS validation is a UX layer, not a security boundary, it runs in the browser and can be trivially bypassed (disable JS, or fetch() directly). Every constraint enforced in the form must be re-validated on the server.
6. Autocomplete attribute neglect
<!-- Missing autocomplete means browsers/password managers have to guess -->
<input type="text" name="cc-number" />
<!-- Right -->
<input type="text" name="cc-number" autocomplete="cc-number" />autocomplete hints (email, name, given-name, street-address, cc-number, one-time-code, etc.) are what let browsers and password managers autofill correctly and are an accessibility requirement under WCAG 2.1 (Success Criterion 1.3.5).
Best Practices
- Every input gets a real
<label>, associated viafor/idor by wrapping.aria-labelis a fallback for when no visible label is appropriate (e.g. an icon-only search field), not a first choice. - Choose the most specific
typefor every input, it's free mobile keyboard optimization and free basic validation. - Use native constraint attributes (
required,min/max,pattern,minlength) before reaching for JS validation; layer the Constraint Validation API on top only for custom error UI.
Further Resources
- MDN, Forms guide
- MDN, Constraint Validation API
- MDN,
autocompleteattribute values - web.dev, Sign-in form best practices
- web.dev, Payment and address form best practices
