Form Patterns — Copy-Paste Reference
Contents
- Complete field markup
- Common autocomplete values
- Validation timing (blur, then input)
- Error summary after failed submit
- Submit button pending state
- Multi-step form skeleton
- Gotchas
Complete field markup
html
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required
autocomplete="email" placeholder="name@example.com"
aria-describedby="email-hint email-error">
<p id="email-hint" class="hint">We only use this for receipts.</p>
<p id="email-error" class="error" hidden></p>
</div>The error node exists from the start (hidden) so aria-describedby stays stable.
Common autocomplete values
| Field | type | autocomplete |
|---|---|---|
| Phone | tel | tel |
| First / last name | text | given-name / family-name |
| Street / city / postal | text | address-line1 / address-level2 / postal-code |
| Country | (select) | country-name |
| Card number / expiry / CVC | text | cc-number / cc-exp / cc-csc |
| New / current password | password | new-password / current-password |
| One-time code | text | one-time-code |
Validation timing (blur, then input)
js
const messages = {
valueMissing: (label) => `Enter your ${label.toLowerCase()}.`,
typeMismatch: () => 'Enter an email address with an @, like name@example.com.',
tooShort: (label, input) => `${label} must be at least ${input.minLength} characters.`,
};
function validate(input) {
const label = input.labels[0].textContent;
const errEl = document.getElementById(input.getAttribute('aria-describedby')
.split(' ').find(id => id.endsWith('-error')));
let msg = '';
for (const key of Object.keys(messages)) {
if (input.validity[key]) { msg = messages[key](label, input); break; }
}
input.setAttribute('aria-invalid', msg ? 'true' : 'false');
errEl.textContent = msg;
errEl.hidden = !msg;
return !msg;
}
document.querySelectorAll('input, select, textarea').forEach((input) => {
input.addEventListener('blur', () => validate(input), { once: false });
input.addEventListener('input', () => {
// re-validate live only after the field has already errored
if (input.getAttribute('aria-invalid') === 'true') validate(input);
});
});Pair with <form novalidate> so these messages replace the browser bubbles.
Error summary after failed submit
js
form.addEventListener('submit', (e) => {
const fields = [...form.querySelectorAll('input, select, textarea')];
const invalid = fields.filter((f) => !validate(f));
if (invalid.length) {
e.preventDefault();
const summary = document.getElementById('form-alert');
summary.innerHTML = `<p>Fix ${invalid.length} error(s):</p><ul>` +
invalid.map((f) => `<li><a href="#${f.id}">${f.labels[0].textContent}</a></li>`).join('') +
'</ul>';
invalid[0].focus();
}
});html
<div id="form-alert" role="alert" tabindex="-1"></div>Submit button pending state
js
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
btn.dataset.label = btn.textContent;
btn.textContent = 'Creating account…';
try {
await submit(new FormData(form));
} catch (err) {
showServerErrors(err); // role="alert" summary; inputs keep their values
} finally {
btn.disabled = false;
btn.textContent = btn.dataset.label;
}
});Disable only during the request — a permanently disabled submit hides why the form can't be sent.
Multi-step form skeleton
html
<form id="wizard">
<p class="progress" aria-live="polite">Step <span id="step-n">1</span> of 3</p>
<fieldset data-step="1"><legend>Your details</legend>…</fieldset>
<fieldset data-step="2" hidden><legend>Shipping</legend>…</fieldset>
<fieldset data-step="3" hidden><legend>Review & confirm</legend>…</fieldset>
<button type="button" id="back" hidden>Back</button>
<button type="button" id="next">Continue</button>
<button type="submit" id="finish" hidden>Place order</button>
</form>Rules encoded above: steps are fieldsets shown one at a time (data survives because nothing unmounts), per-step validation runs on "Continue", the last step is a read-only review, and the progress line is announced on change.
Gotchas
<form novalidate>disables browser bubbles but NOT thevalidityAPI — exactly what you want for custom messages.- Disabled inputs are skipped on submit AND invisible to autofill/screen readers in some browsers — prefer
readonlyfor locked-but-submitted values. patternis anchored (implicit^…$) and silently fails on typos in the regex; always pair with atitle/error message that states the format.- iOS zooms into inputs with font-size <16px — keep inputs ≥16px.
- Numeric codes (postal, OTP): use
inputmode="numeric" pattern="[0-9]*", NOTtype="number"(which strips leading zeros and adds spinners). - Autofocus on page load disorients screen-reader users — reserve
autofocusfor single-purpose pages (search, login).