Why forms deserve care
Forms are where users give you money, sign up, search and send messages. They are also where frustration concentrates: a missing label, a confusing error or an input that opens the wrong keyboard can lose a user in seconds.
The good news is that HTML already solves most of this. With proper labels, the right input types and native constraints, you get accessible, mobile-friendly, validated forms before writing a line of JavaScript.
Form structure
A form wraps its controls and declares where and how to submit them.
<!-- signup.html -->
<form action="/signup" method="post">
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="password">Password</label>
<input id="password" name="password" type="password" minlength="8" required />
<button type="submit">Create account</button>
</form>
- action is the URL that receives the data.
- method is
getorpost. - name on each control is the key in the submitted data.
- type=“submit” triggers submission; a bare button inside a form defaults to submit, so use
type="button"for other actions.
Labels
Every control needs a label. A label is announced by screen readers, acts as a larger click target and stays visible after the field is filled.
<!-- label.html -->
<label for="phone">Phone number</label>
<input id="phone" name="phone" type="tel" autocomplete="tel" />
You can also wrap the control inside the label, which associates them implicitly:
<label>
Subscribe to the newsletter
<input type="checkbox" name="subscribe" />
</label>
Placeholders are not labels. They disappear once a user types, often have poor contrast and are not reliably announced. Use a real label and, if helpful, a hint.
Input types
Choosing the right type gives users the correct mobile keyboard and enables built-in validation.
| Type | Use for | Benefit |
|---|---|---|
| Email addresses | Email keyboard, format check | |
| tel | Phone numbers | Numeric keypad |
| number | Numeric values | Numeric input, min/max/step |
| url | Web addresses | URL keyboard, format check |
| date | Dates | Date picker |
| password | Secrets | Masked input |
| search | Search fields | Search keyboard, clear button |
| file | Uploads | File picker and type filtering |
Use autocomplete with standard tokens like email, given-name, postal-code and new-password so browsers and password managers can help.
Grouping controls
Group related controls so their relationship is clear to everyone.
<!-- address.html -->
<fieldset>
<legend>Shipping address</legend>
<label for="street">Street</label>
<input id="street" name="street" autocomplete="address-line1" />
<label for="city">City</label>
<input id="city" name="city" autocomplete="address-level2" />
</fieldset>
fieldset and legend group radio buttons and related fields, which screen readers announce as a set. For radio groups, always use a fieldset with a legend.
Native validation
HTML provides constraint validation through attributes.
<!-- validate.html -->
<input type="email" name="email" required />
<input type="password" name="password" minlength="8" required />
<input type="number" name="age" min="18" max="120" step="1" />
<input type="text" name="handle" pattern="[a-z0-9_]{3,15}" />
<input type="url" name="website" />
The browser blocks submission and shows a message when a constraint fails. You can style the states with CSS:
/* styles.css */
input:invalid { border-color: #dc2626; }
input:valid { border-color: #16a34a; }
input:focus-visible { outline: 2px solid #2563eb; }
Native messages are functional but terse and untranslated. For important forms, provide your own clear messages using the Constraint Validation API:
// validate.js
const email = document.querySelector("#email");
email.addEventListener("invalid", () => {
email.setCustomValidity("");
if (email.validity.valueMissing) {
email.setCustomValidity("Please enter your email address.");
} else if (email.validity.typeMismatch) {
email.setCustomValidity("That does not look like an email address.");
}
});
email.addEventListener("input", () => email.setCustomValidity(""));
Remember to clear the custom message on input, or the field stays invalid.
Accessible errors
Validation is only helpful if users understand what went wrong.
- Show a clear message in text next to the field, not just a red border.
- Associate it with the control using
aria-describedby. - Mark the field with
aria-invalid="true". - Move focus to the first invalid field on submit.
- Summarise errors at the top for long forms.
<!-- error.html -->
<label for="email">Email address</label>
<input id="email" name="email" type="email" required
aria-invalid="true" aria-describedby="email-error" />
<p id="email-error" class="error">Please enter a valid email address.</p>
Never rely on colour alone; pair it with text and an icon.
Always validate on the server
Client-side validation is a user-experience feature, not a security control. Anyone can edit the page, disable JavaScript or send a request directly. The server must validate types, lengths, formats and permissions, and return clear errors the client can display. See the Web Security guide.
Best practices
- Give every control a visible, associated label.
- Use the correct input type and
autocompletetokens. - Group related controls with
fieldsetandlegend. - Prefer native constraints, then enhance with the Constraint Validation API.
- Show accessible, specific error messages near the field.
- Validate again on the server for every request.
- Keep forms short and ask only for what you need.
Common mistakes
- Using placeholders instead of labels.
- Missing
nameattributes, so fields are not submitted. - Submitting sensitive data with GET.
- Relying on client-side validation for security.
- Communicating errors with colour alone.
- Resetting the whole form on a validation error and losing input.
Where to go next
Forms combine markup, accessibility and validation. Strengthen the markup with Semantic HTML, make it inclusive with the Accessibility guide, and handle submissions with JavaScript. Then audit one form in your app against the checklist above.