This project puts the forms chapter together: field rules, helpful messages, accessible errors and a result.
<style>
.form { font-family: system-ui, sans-serif; max-width: 320px; }
.field { margin-bottom: 12px; }
label { display: block; font-weight: 600; margin-bottom: 4px; }
input { width: 100%; box-sizing: border-box; padding: 8px; font: inherit; border: 2px solid #b9b2ad; border-radius: 6px; }
input[aria-invalid="true"] { border-color: #b00020; }
input:focus-visible { outline: 3px solid #1a527655; }
.error { color: #b00020; font-size: 14px; min-height: 1.2em; margin: 2px 0 0; }
.hint { color: #555; font-size: 13px; }
button { font: inherit; padding: 8px 16px; background: #1a5276; color: #fff; border: 0; border-radius: 6px; cursor: pointer; }
.strength { height: 6px; background: #eee; border-radius: 3px; margin-top: 4px; overflow: hidden; }
.strength span { display: block; height: 100%; width: 0; background: #b00020; transition: width .2s, background .2s; }
</style>
<form class="form" id="form" novalidate>
<div class="field">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" aria-describedby="name-error">
<p class="error" id="name-error"></p>
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" aria-describedby="email-error">
<p class="error" id="email-error"></p>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password" aria-describedby="pw-hint password-error">
<div class="strength" aria-hidden="true"><span id="bar"></span></div>
<p class="hint" id="pw-hint">At least 8 characters, with a letter and a number.</p>
<p class="error" id="password-error"></p>
</div>
<button>Create account</button>
<p role="status" id="result"></p>
</form>
<script>
const rules = {
name: v => (v.length < 2 ? "Please enter your name (at least 2 letters)." : ""),
email: v => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? "" : "Enter an email address like name@example.com."),
password: v => {
if (v.length < 8) return "Use at least 8 characters.";
if (!/[a-zA-Z]/.test(v) || !/\d/.test(v)) return "Include at least one letter and one number.";
return "";
}
};
const form = document.querySelector("#form");
function check(input) {
const message = rules[input.name](input.value.trim());
document.getElementById(input.name + "-error").textContent = message;
input.setAttribute("aria-invalid", String(message !== ""));
return message === "";
}
function strength(password) {
let score = 0;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
return score; // 0 to 4
}
form.addEventListener("input", event => {
if (event.target.name === "password") {
const score = strength(event.target.value);
const bar = document.querySelector("#bar");
bar.style.width = score * 25 + "%";
bar.style.background = score < 2 ? "#b00020" : score < 4 ? "#d98b00" : "#1e8449";
}
if (event.target.getAttribute("aria-invalid") === "true") check(event.target); // re-check once an error was shown
});
form.addEventListener("focusout", event => {
if (event.target.name in rules && event.target.value !== "") check(event.target);
});
form.addEventListener("submit", event => {
event.preventDefault();
const inputs = [...form.querySelectorAll("input")];
const results = inputs.map(check); // check every field
const result = document.querySelector("#result");
if (results.every(Boolean)) {
result.textContent = `Welcome, ${form.elements.name.value.trim()}!`;
form.reset();
} else {
result.textContent = "";
inputs[results.indexOf(false)].focus();
}
});
</script>
The design decisions
- One rules object maps each field name to a function that returns an error message (or an empty string). Adding a field means adding a rule.
- When to validate: on
focusout(after leaving a field), on submit (everything), and oninputonly after an error has appeared. People are not nagged while they type their first characters. - Accessible errors: text under each field, linked with
aria-describedby,aria-invalid, and focus moved to the first invalid field. - Strength meter is decorative (
aria-hidden). The real requirement is written in text. novalidatehands control to our messages. See Custom Validation.
Security reminder
This is only the front end. A real server must validate everything again, store passwords with a proper hashing algorithm, and never trust the browser.
Try changing it
- Add a Confirm password field that must match.
- Show or hide the password with a toggle button (
input.type = "text"). - Disable the submit button while “sending” and show a loading message.
- Check whether the email is “taken” with a pretend async function (Async/Await).