Project 12 · Advanced
Form Validation
A sign-up form that won't let a mistake through. You'll build it twice: once letting the browser check the fields with a few HTML attributes, and once writing the checks yourself, so every message is clear, specific, and read out to screen reader users.
- 7 HTML ideas
- 3 CSS ideas
- 7 JavaScript ideas
- 2 ways to build it
Why build it
Checking fields and writing clear error messages is what every sign-up, checkout, booking, and contact form needs. With these skills you can build a password field that explains its rules, a check for phone numbers or postcodes, and a form that takes people straight to what's wrong. Clear messages are what turn a form people give up on into one they finish.
Skills you'll earn
- Check fields with the browser's own rules: required, type, minlength, and pattern
- Show mistakes only after someone has used a field, with :user-invalid
- Link each error message to its field so screen readers read it, with aria-describedby
- Check the shape of text, like a postcode, with regular expressions
- Take people straight to the first mistake by moving focus to it
01
Try it
Play with it first, then read the code that makes it work.
HTML attributes like required, type="email", and minlength do the checking, with almost no JavaScript. The catch: the browser writes the messages, and they're often vague.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Form Validation</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="card">
<!-- The browser checks these attributes before it lets the form submit. -->
<form class="signup" id="signup">
<h1 class="signup-title">Create an account</h1>
<div class="field">
<label for="name">Full name</label>
<!-- required: the form won't submit while this is empty. -->
<input id="name" name="name" type="text" autocomplete="name" required />
</div>
<div class="field">
<label for="email">Email</label>
<!-- type="email" also checks it looks like an email address. -->
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
/>
</div>
<div class="field">
<label for="password">Password</label>
<!-- The hint is read with the field, so everyone knows the rules first. -->
<p class="hint" id="password-hint">
At least 8 characters, including a number.
</p>
<!-- minlength counts characters; pattern needs a digit somewhere. -->
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
minlength="8"
pattern=".*\d.*"
aria-describedby="password-hint"
required
/>
</div>
<button type="submit" class="submit">Create account</button>
</form>
<!-- tabindex="-1" lets JavaScript move focus here once it's shown. -->
<div class="success" id="success" tabindex="-1" hidden>
<h1 class="signup-title">You're signed up!</h1>
<p>Check your inbox for a welcome email.</p>
</div>
</main>
<script src="script.js"></script>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
HTML attributes like required, type="email", and minlength do the checking, with almost no JavaScript. The catch: the browser writes the messages, and they're often vague.
HTMLthe structure
- 01
Block empty fields with
requiredHTML can check a form before it's sent, using attributes on the inputs.
requiredmeans the field must have a value. If you press Create account while it's empty, the browser stops the submit, moves focus to the field, and shows its own message beside it. None of this needs JavaScript.<!-- The browser checks these attributes before it lets the form submit. --> <form class="signup" id="signup"> <h1 class="signup-title">Create an account</h1> <div class="field"> <label for="name">Full name</label> <!-- required: the form won't submit while this is empty. --> <input id="name" name="name" type="text" autocomplete="name" required /> </div> - 02
Check emails with
type="email"type="email"tells the browser the field holds an email address. On submit, it checks the value has the shape of one, like name@example.com, and on phones it shows a keyboard with @ on it.autocomplete="email"says what the field is for, so the browser can offer to fill in a saved address.<div class="field"> <label for="email">Email</label> <!-- type="email" also checks it looks like an email address. --> <input id="email" name="email" type="email" autocomplete="email" required /> </div> - 03
Set password rules with
minlengthandpatternminlength="8"needs at least 8 characters.patternneeds the whole value to match a regular expression, a short code that describes text. Here.*\d.*means "a digit somewhere". Ifpatternfails, the browser only says "match the requested format", so the hint spells out the rules, andaria-describedbymakes screen readers read it with the field.<div class="field"> <label for="password">Password</label> <!-- The hint is read with the field, so everyone knows the rules first. --> <p class="hint" id="password-hint"> At least 8 characters, including a number. </p> <!-- minlength counts characters; pattern needs a digit somewhere. --> <input id="password" name="password" type="password" autocomplete="new-password" minlength="8" pattern=".*\d.*" aria-describedby="password-hint" required /> </div> - 04
Make the success message focusable
tabindexcontrols whether an element can receive focus.-1keeps the success message out of the Tab order, so keyboard users don't land on it while filling in the form, but it lets JavaScript move focus there withfocus(). When the message appears and gets focus, screen readers read it out.<!-- tabindex="-1" lets JavaScript move focus here once it's shown. --> <div class="success" id="success" tabindex="-1" hidden> <h1 class="signup-title">You're signed up!</h1> <p>Check your inbox for a welcome email.</p> </div>
CSSthe look
- 05
Style mistakes with
:user-invalid:invalidmatches any field that fails its checks, including a required field that's still empty. On a fresh form, every field would look wrong before you'd typed anything.:user-invalidonly matches once someone has changed the field or tried to submit, so the red border appears when there's a real mistake./* :user-invalid waits until a field is used; :invalid would flag it at once. */ .field input:user-invalid { border-color: #b91c1c; }
JavaScriptthe behaviour
- 06
Act only on a valid form
With built-in checks, the browser doesn't fire the
submitevent until every field passes, so the listener never sees a bad form.preventDefault()stops the page reloading, then the form is hidden, the success message is shown, and focus moves to it. A real site would send the data at this point.// "submit" only fires once every field passes the browser's checks. form.addEventListener("submit", (event) => { // Stop the page reloading; a real site would send the data here. event.preventDefault(); form.hidden = true; successMessage.hidden = false; // Move focus to the message, so screen readers announce it. successMessage.focus(); });
HTMLthe structure
- 01
Turn off the browser's bubbles with
novalidatenovalidateon the<form>turns off the browser's own checking on submit: it no longer blocks the form or shows its message bubbles. That leaves room for your own, clearer messages. Thetypeattributes still work: phones still show the email keyboard, and JavaScript can still ask the browser whether the email looks valid.<!-- novalidate turns off the browser's bubbles; JavaScript checks instead. --> <form class="signup" id="signup" novalidate> - 02
Connect each error with
aria-describedbyaria-describedbylinks a field to another element by its id, and screen readers read that element along with the field's label. Each field here has an empty<p class="error">under it, andaria-describedbypoints at it. When JavaScript writes a message into it, screen reader users hear the error when they reach the field.<div class="field"> <label for="name">Full name</label> <!-- aria-describedby reads the error out with the field. --> <input id="name" name="name" type="text" autocomplete="name" aria-describedby="name-error" /> <!-- Empty until there's a problem with the field. --> <p class="error" id="name-error"></p> </div> - 03
Point at the hint and the error together
aria-describedbycan take several ids separated by spaces, and screen readers read them in that order. The password field points atpassword-hintand thenpassword-error, so people hear the rules first, then whatever went wrong. Until there's an error, only the hint is read, because the error paragraph is empty.<div class="field"> <label for="password">Password</label> <p class="hint" id="password-hint"> At least 8 characters, including a number. </p> <!-- Two ids: the hint and the error are both read with the field. --> <input id="password" name="password" type="password" autocomplete="new-password" aria-describedby="password-hint password-error" /> <p class="error" id="password-error"></p> </div>
CSSthe look
- 04
Style errors from
aria-invalidaria-invalid="true"tells screen readers a field has a problem, so they announce it as invalid. JavaScript sets it on any field that fails its rule. CSS can select elements by attribute, so[aria-invalid="true"]turns the same fields' borders red. One attribute drives both what you see and what you hear, so they can't disagree./* JavaScript sets aria-invalid, and the same attribute styles the field. */ .field input[aria-invalid="true"] { border-color: #b91c1c; } - 05
Hide empty messages with
:empty:emptymatches an element with nothing inside it, not even text. Each error paragraph starts empty, so.error:emptyhides it withdisplay: noneand it takes up no space. When JavaScript writes a message in, it's no longer empty, so it appears and pushes the next field down. Clearing the text hides it again./* Empty errors take no space; a message pushes the next field down. */ .error { margin: 0.375rem 0 0; font-size: 0.875rem; color: #b91c1c; } .error:empty { display: none; }
JavaScriptthe behaviour
- 06
Keep one rule per field in an object
An object can hold functions as well as plain values.
rulesuses each field'snameas a key, and each value is a function that checks that field. It returns an error message, or an empty string when the value is fine. Because every rule has the same shape, adding a field to the form means adding one rule.// One rule per field, by its name. Each returns an error message, or "" // when the value is fine. const rules = { name: (input) => (input.value.trim() ? "" : "Enter your full name."), email: (input) => { if (!input.value) return "Enter your email address."; // validity still checks type="email" for us, even with novalidate. if (input.validity.typeMismatch) { return "Enter an email address like name@example.com."; } return ""; }, password: (input) => { if (input.value.length < 8) return "Use at least 8 characters."; // A regular expression: \d matches any digit. if (!/\d/.test(input.value)) return "Include at least one number."; return ""; }, confirm: (input) => { if (!input.value) return "Type your password again."; if (input.value !== passwordInput.value) return "The passwords don't match."; return ""; }, }; - 07
Reuse the browser's checks with
validityEvery input has a
validityproperty that reports which of the field's own rules the value breaks, even withnovalidateon the form.typeMismatchistruewhen the value doesn't fit the field'stype, here an email field that doesn't hold an email address. Theemailrule uses it, so you don't have to write your own email check.email: (input) => { if (!input.value) return "Enter your email address."; // validity still checks type="email" for us, even with novalidate. if (input.validity.typeMismatch) { return "Enter an email address like name@example.com."; } return ""; }, - 08
Test text with a regular expression
A regular expression is a pattern for matching text, written between slashes. In
/\d/,\dmeans any digit from 0 to 9..test()returnstrueif the text contains a match anywhere. Thepasswordrule checks the length first, then uses this to make sure there's at least one number.password: (input) => { if (input.value.length < 8) return "Use at least 8 characters."; // A regular expression: \d matches any digit. if (!/\d/.test(input.value)) return "Include at least one number."; return ""; }, - 09
Check a field and show the result
validate()is the one place that checks a field and updates the page. It looks up the field's rule withrules[input.name], writes the message into the matching error paragraph, and setsaria-invalidto"true"or"false". It returnstruewhen the field passed, so the submit code can tell whether the whole form is valid.// Check one field, show or clear its message, and say whether it passed. function validate(input) { const message = rules[input.name](input); document.querySelector(`#${input.id}-error`).textContent = message; // aria-invalid tells screen readers the field has a problem. input.setAttribute("aria-invalid", message ? "true" : "false"); return message === ""; } - 10
Check at the right moments
Showing errors while someone is still typing feels like nagging. The
blurevent fires when focus leaves a field, so each field is checked once you move on, if you've typed something. Theinputevent fires on every change, but it only rechecks a field that already has an error, so the message clears the moment it's fixed.inputs.forEach((input) => { // Check when someone leaves a field they've typed in, not while typing. input.addEventListener("blur", () => { if (input.value) validate(input); }); // Once a field has an error, re-check as they type, so it clears at once. input.addEventListener("input", () => { if (input.getAttribute("aria-invalid") === "true") validate(input); }); }); - 11
Show every error, then focus the first
On submit,
[...inputs]turns the list of fields into an array, andmap()runsvalidateon each one, so every error appears at once instead of one at a time. If any result isfalse, focus moves to the first field witharia-invalid="true", so keyboard and screen reader users land straight on the problem.form.addEventListener("submit", (event) => { event.preventDefault(); // map checks every field, so all the errors show at once, not just one. const results = [...inputs].map(validate); if (results.includes(false)) { // Take keyboard and screen reader users straight to the first problem. form.querySelector('[aria-invalid="true"]').focus(); return; } form.hidden = true; successMessage.hidden = false; successMessage.focus(); });
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Show a password strength meter as people type.
- Challenge 02
Add a Show password button that switches the field between hidden and visible.
- Challenge 03
Check the email against a list of addresses that are already taken.
Finished Form Validation?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.
Article coming soon. See the series so far (opens in a new tab)