Nobody likes filling out forms. I've watched user testing sessions where people abandon checkout flows because a phone number field rejected a perfectly valid format, or a password field had requirements that only appeared after submission. Forms are where users convert — or bounce. And yet, most developers treat them as an afterthought.
I've built hundreds of forms over the years. Some of them were genuinely good. Many early ones were terrible. Here's everything I've learned about building forms that people actually complete.
Start with Native HTML: It Does More Than You Think
Before reaching for a validation library, look at what the browser gives you for free. HTML5 introduced a bunch of input types and validation attributes that handle the common cases without a single line of JavaScript.
Input Types That Matter
<!-- Email — validates format, shows @ keyboard on mobile --> <input type="email" name="email" required> <!-- Phone — shows numeric keyboard on mobile --> <input type="tel" name="phone"> <!-- URL — validates protocol inclusion --> <input type="url" name="website"> <!-- Number — with min/max/step constraints --> <input type="number" name="quantity" min="1" max="99" step="1"> <!-- Date — native date picker, no library needed --> <input type="date" name="birthday" min="1920-01-01" max="2010-12-31"> <!-- Search — shows clear button, submits on Enter --> <input type="search" name="query">
Each type triggers the right mobile keyboard, provides built-in validation, and communicates intent to assistive technology. Using type="text" for an email field means your mobile users are hunting for the @ symbol on a QWERTY keyboard. Don't make them do that.
Validation Attributes
Native validation goes way beyond required:
<!-- Required field -->
<input type="text" required>
<!-- Min/max length -->
<input type="text" minlength="3" maxlength="50">
<!-- Pattern — regex validation -->
<input type="text" pattern="[A-Za-z]{2,}" title="Letters only, 2+ chars">
<!-- Combined: username field -->
<input type="text" name="username"
required minlength="3" maxlength="20"
pattern="[a-z0-9_]+"
title="Lowercase letters, numbers, and underscores only">
The pattern attribute accepts any regex. The title attribute becomes the validation tooltip message. Together, they cover surprisingly complex rules without JavaScript.
Tip: The title attribute on a pattern-validated input isn't just a tooltip — screen readers announce it as part of the field's description. Write it like a human instruction ("Letters only, minimum 2 characters") not a regex explanation.
Accessibility: Non-Negotiable Fundamentals
I'll be blunt: if your form doesn't have proper labels, it's broken. Not "could be better." Broken. Screen reader users literally can't use it.
Every Input Needs a Label
<!-- Option 1: Explicit label with for/id --> <label for="email">Email address</label> <input type="email" id="email" name="email"> <!-- Option 2: Wrapping label --> <label> Email address <input type="email" name="email"> </label>
Both work. I prefer Option 1 because it's more flexible for styling. The critical thing is that clicking the label focuses the input. If that doesn't happen, the association is broken.
What about placeholder text? Placeholders are not labels. They disappear when you start typing. If someone gets distracted and comes back to a half-filled form, they have no idea what's in each field. Use real labels.
Error Messages and aria-describedby
When validation fails, sighted users see a red message. Screen reader users need to hear it. Use aria-describedby to associate error text with the input:
<label for="password">Password</label>
<input type="password" id="password" name="password"
required minlength="8"
aria-describedby="password-help password-error"
aria-invalid="false">
<span id="password-help">Must be at least 8 characters</span>
<span id="password-error" role="alert" hidden></span>
When validation fails, JavaScript unhides the error span, sets aria-invalid="true", and populates the error message. The role="alert" ensures the screen reader announces it immediately.
Group Related Fields
<fieldset>
<legend>Shipping address</legend>
<label for="street">Street</label>
<input type="text" id="street" name="street">
<label for="city">City</label>
<input type="text" id="city" name="city">
<label for="zip">ZIP code</label>
<input type="text" id="zip" name="zip" inputmode="numeric"
pattern="[0-9]{5,6}" title="5 or 6 digit ZIP code">
</fieldset>
<fieldset> and <legend> provide grouping context to screen readers. "Street" alone is ambiguous — is it shipping or billing? The fieldset legend clarifies.
Custom Validation with JavaScript
Native validation handles the basics, but real forms need more. Password confirmation, conditional fields, async checks (does this username already exist?) — that's where JavaScript comes in.
The Constraint Validation API
The browser exposes a validation API on every form element. Use it instead of reinventing validation from scratch:
const form = document.querySelector('#signup-form');
const password = form.querySelector('#password');
const confirm = form.querySelector('#confirm-password');
confirm.addEventListener('input', () => {
if (confirm.value !== password.value) {
confirm.setCustomValidity('Passwords do not match');
} else {
confirm.setCustomValidity(''); // Clear the error
}
});
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
form.reportValidity(); // Shows native validation UI
return;
}
// Form is valid, proceed with submission
});
setCustomValidity() lets you add custom error messages that integrate with the browser's built-in validation UI. checkValidity() returns true/false. reportValidity() triggers the visual error display. You're extending the native system, not replacing it.
Real-Time Validation (Done Right)
Showing errors while users are still typing is one of the most annoying things a form can do. "Invalid email" — I'm literally in the middle of typing it. Wait until the user leaves the field:
const inputs = form.querySelectorAll('input');
inputs.forEach(input => {
// Validate on blur (leaving the field), not on input
input.addEventListener('blur', () => {
validateField(input);
});
// But once they've seen an error, validate on input
// so the error clears as they fix it
input.addEventListener('input', () => {
if (input.dataset.touched) {
validateField(input);
}
});
});
function validateField(input) {
input.dataset.touched = 'true';
const errorEl = document.getElementById(`${input.name}-error`);
if (!input.checkValidity()) {
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = input.validationMessage;
errorEl.hidden = false;
} else {
input.setAttribute('aria-invalid', 'false');
errorEl.textContent = '';
errorEl.hidden = true;
}
}
This pattern — validate on blur, then on input after the first blur — gives the best user experience. Users see errors after they move to the next field, and errors clear immediately as they type a correction.
Tip: If you want to suppress the browser's default validation popups and use your own styled messages, add novalidate to the <form> tag. You'll need to call checkValidity() manually in your submit handler — but you get full control over the error display.
UX Patterns That Reduce Abandonment
Show Requirements Before Errors
Don't hide password requirements until the user fails. Show them upfront:
<label for="password">Create password</label>
<input type="password" id="password" name="password"
required minlength="8"
aria-describedby="pw-requirements">
<ul id="pw-requirements">
<li id="pw-length">At least 8 characters</li>
<li id="pw-upper">One uppercase letter</li>
<li id="pw-number">One number</li>
</ul>
As users type, you can mark each requirement as satisfied (toggle a class, change the icon). They can see exactly what's left. No surprises.
Smart Defaults and Autocomplete
Use the autocomplete attribute generously. It tells the browser (and password managers) what each field contains:
<input type="text" name="name" autocomplete="name"> <input type="email" name="email" autocomplete="email"> <input type="tel" name="phone" autocomplete="tel"> <input type="text" name="address" autocomplete="street-address"> <input type="text" name="city" autocomplete="address-level2"> <input type="text" name="zip" autocomplete="postal-code"> <input type="password" name="new-password" autocomplete="new-password">
With proper autocomplete attributes, returning users fill out your form in seconds. One tap and everything populates. I've seen this alone reduce checkout abandonment by 20%+.
Don't Ask for What You Don't Need
Every field you add to a form reduces completion rates. Do you really need a phone number for a newsletter signup? Does a blog comment need an email? Cut ruthlessly. The best form is the shortest one that still gives you what you need.
Styling Forms Without Breaking Them
Custom-styled form elements can look great, but they're easy to break. Here's a CSS pattern that styles inputs while preserving accessibility:
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 20px;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
color: #334155;
}
.form-field input,
.form-field select,
.form-field textarea {
padding: 10px 14px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s;
}
.form-field input:focus,
.form-field select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
/* Validation states */
.form-field input:invalid:not(:placeholder-shown):not(:focus) {
border-color: #ef4444;
}
.form-field input:valid:not(:placeholder-shown) {
border-color: #22c55e;
}
That :not(:placeholder-shown) trick is key — it prevents empty fields from showing validation states. The red border only appears after the user has typed something and it's invalid. Without this, every required field shows as red on page load. Bad UX.
The :not(:focus) part prevents the field from turning red while the user is actively typing. They see the error after they leave the field. Same principle as the JavaScript approach, but pure CSS.
Custom Checkboxes and Radios
The default checkbox and radio styles are notoriously hard to customize. Here's a modern approach that keeps the native input for accessibility while styling a visual replacement:
.custom-checkbox {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.custom-checkbox input[type="checkbox"] {
appearance: none;
width: 20px;
height: 20px;
border: 2px solid #cbd5e1;
border-radius: 4px;
transition: all 0.15s;
flex-shrink: 0;
}
.custom-checkbox input:checked {
background: #3b82f6;
border-color: #3b82f6;
background-image: url("data:image/svg+xml,..."); /* checkmark SVG */
background-size: 14px;
background-position: center;
background-repeat: no-repeat;
}
.custom-checkbox input:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
Using appearance: none strips the default styling while keeping the input functional for keyboard navigation and screen readers. The :focus-visible pseudo-class shows the focus ring only for keyboard users, not mouse clicks.
Convert Your Form Designs to Images
Capture beautifully styled HTML forms as PNG images — perfect for design documentation and client presentations.
Try HTML to PNG Converter →Putting It All Together
Here's a condensed checklist I mentally run through every time I build a form:
- Use correct input types — email, tel, url, number, date. Mobile keyboards matter.
- Label everything — visible labels, not just placeholders. Use
for/idassociation. - Add autocomplete attributes — let browsers and password managers help your users.
- Show requirements upfront — don't surprise users with rules after submission.
- Validate on blur, clear on input — the timing of error messages matters enormously.
- Use
aria-invalidandaria-describedby— make errors perceivable to screen readers. - Group related fields with
fieldsetandlegend. - Keep it short — remove every field that isn't strictly necessary.
Forms are the most important UI component on most websites. They're where visitors become customers, users become members, and browsers become participants. Get them right, and conversion improves. Get them wrong, and people leave — often without telling you why.
The good news? The browser gives you an incredible amount for free. Use semantic HTML, add proper labels and autocomplete, layer on the Constraint Validation API for custom rules, and respect your users' time. That's the whole formula. Simple, but not easy.