Form — Real-time inline validation
A polished signup form that validates each field the moment it matters — on blur first, then live as you type, with debounced checks so messages never flicker. Specific error text, success checkmarks, an aria-invalid and aria-describedby wiring, a password strength meter with live rules, phone auto-formatting, and a submit button that stays disabled until every field is genuinely valid. Ends in an animated success state.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px 20px;
background:
radial-gradient(900px 420px at 12% -8%, var(--brand-50), transparent 60%),
radial-gradient(700px 360px at 108% 12%, var(--accent-soft), transparent 55%),
var(--bg);
}
/* ── Card ── */
.card {
position: relative;
width: min(460px, 100%);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 30px 30px 26px;
overflow: hidden;
}
.card__head {
margin-bottom: 22px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--brand-700);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.18);
padding: 5px 10px;
border-radius: 999px;
}
.badge svg {
color: var(--brand);
}
.card__title {
margin-top: 14px;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
}
.card__sub {
margin-top: 6px;
font-size: 14px;
color: var(--muted);
}
/* ── Form ── */
.form {
display: grid;
gap: 18px;
}
.field {
display: grid;
gap: 7px;
}
.field__label {
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
}
.req {
color: var(--danger);
font-weight: 700;
}
.control {
position: relative;
display: flex;
align-items: center;
}
.input {
width: 100%;
font: inherit;
font-size: 15px;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
padding: 12px 42px 12px 14px;
box-shadow: var(--sh-1);
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
background 0.16s ease;
}
.input::placeholder {
color: #aab0c8;
}
.input:hover:not(:focus) {
border-color: rgba(16, 19, 34, 0.26);
}
.input:focus-visible,
.input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 4px rgba(91, 91, 240, 0.16);
}
/* Status icon inside control */
.control__icon {
position: absolute;
right: 14px;
width: 18px;
height: 18px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.7);
transition:
opacity 0.18s ease,
transform 0.18s ease;
pointer-events: none;
}
.control__icon::before {
content: "";
width: 18px;
height: 18px;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
/* ── Valid state ── */
.field.is-valid .input {
border-color: var(--ok);
}
.field.is-valid .input:focus-visible,
.field.is-valid .input:focus {
box-shadow: 0 0 0 4px rgba(47, 158, 111, 0.16);
}
.field.is-valid .control__icon {
opacity: 1;
transform: scale(1);
color: var(--ok);
}
.field.is-valid .control__icon::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%232f9e6f' d='M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E");
}
/* ── Error state ── */
.field.is-error .input {
border-color: var(--danger);
background: #fff7f5;
}
.field.is-error .input:focus-visible,
.field.is-error .input:focus {
box-shadow: 0 0 0 4px rgba(212, 80, 62, 0.16);
}
.field.is-error .control__icon {
opacity: 1;
transform: scale(1);
color: var(--danger);
}
.field.is-error .control__icon::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23d4503e' d='M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20m1 15h-2v-2h2zm0-4h-2V7h2z'/%3E%3C/svg%3E");
}
/* Password reveal toggle pushes status icon over */
.field[data-field="password"] .input {
padding-right: 46px;
}
.reveal {
position: absolute;
right: 8px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
border-radius: var(--r-sm);
transition:
color 0.15s ease,
background 0.15s ease;
}
.reveal:hover {
color: var(--ink-2);
background: rgba(16, 19, 34, 0.05);
}
.reveal:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.3);
}
.reveal__off {
display: none;
}
.reveal[aria-pressed="true"] .reveal__on {
display: none;
}
.reveal[aria-pressed="true"] .reveal__off {
display: block;
}
/* ── Helper / error text ── */
.help {
font-size: 12.5px;
color: var(--muted);
min-height: 1.1em;
transition: color 0.15s ease;
}
.help.is-error {
color: var(--danger);
font-weight: 500;
}
.help.is-ok {
color: var(--ok);
font-weight: 500;
}
/* ── Password strength meter ── */
.meter {
display: flex;
align-items: center;
gap: 10px;
margin-top: 1px;
}
.meter__track {
flex: 1;
height: 6px;
background: rgba(16, 19, 34, 0.08);
border-radius: 999px;
overflow: hidden;
}
.meter__bar {
display: block;
height: 100%;
width: 0;
border-radius: 999px;
background: var(--danger);
transition:
width 0.25s ease,
background 0.25s ease;
}
.meter[data-level="2"] .meter__bar {
background: var(--warn);
}
.meter[data-level="3"] .meter__bar {
background: var(--accent);
}
.meter[data-level="4"] .meter__bar {
background: var(--ok);
}
.meter__label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
min-width: 56px;
text-align: right;
}
/* ── Password rules ── */
.rules {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
margin-top: 2px;
}
.rule {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
transition: color 0.15s ease;
}
.rule__dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1.5px solid var(--line-2);
position: relative;
flex: none;
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.rule.is-met {
color: var(--ok);
}
.rule.is-met .rule__dot {
background: var(--ok);
border-color: var(--ok);
}
.rule.is-met .rule__dot::after {
content: "";
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E")
center / 11px no-repeat;
}
/* ── Submit ── */
.submit {
position: relative;
margin-top: 4px;
width: 100%;
font: inherit;
font-size: 15px;
font-weight: 700;
color: var(--white);
background: linear-gradient(180deg, var(--brand), var(--brand-d));
border: none;
border-radius: var(--r-md);
padding: 14px 16px;
cursor: pointer;
box-shadow: 0 8px 18px rgba(70, 70, 214, 0.28);
transition:
transform 0.12s ease,
box-shadow 0.16s ease,
background 0.16s ease,
opacity 0.16s ease;
}
.submit:hover:not(:disabled) {
background: linear-gradient(180deg, var(--brand-d), var(--brand-700));
}
.submit:active:not(:disabled) {
transform: translateY(1px);
}
.submit:focus-visible {
outline: none;
box-shadow:
0 0 0 4px rgba(91, 91, 240, 0.3),
0 8px 18px rgba(70, 70, 214, 0.28);
}
.submit:disabled {
cursor: not-allowed;
opacity: 0.55;
background: var(--ink-2);
box-shadow: none;
}
.submit__spin {
display: none;
width: 16px;
height: 16px;
margin-left: 9px;
vertical-align: -3px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: var(--white);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.submit.is-loading .submit__spin {
display: inline-block;
}
.submit.is-loading .submit__label {
opacity: 0.85;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.form__foot {
font-size: 12px;
color: var(--muted);
text-align: center;
}
.link {
color: var(--brand-d);
font-weight: 600;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
/* ── Success overlay ── */
.done {
position: absolute;
inset: 0;
background: var(--surface);
display: grid;
place-content: center;
justify-items: center;
text-align: center;
gap: 8px;
padding: 32px;
animation: fade 0.3s ease;
}
.done[hidden] {
display: none;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(8px);
}
}
.done__seal {
width: 64px;
height: 64px;
display: grid;
place-items: center;
color: var(--white);
background: linear-gradient(180deg, var(--ok), #257a57);
border-radius: 50%;
box-shadow: 0 8px 20px rgba(47, 158, 111, 0.35);
margin-bottom: 6px;
animation: pop 0.4s cubic-bezier(0.18, 1.4, 0.4, 1);
}
@keyframes pop {
from {
transform: scale(0.4);
opacity: 0;
}
}
.done__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
}
.done__text {
font-size: 14px;
color: var(--muted);
max-width: 26ch;
}
.done__text strong {
color: var(--ink-2);
}
.ghost {
margin-top: 10px;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--brand-d);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.2);
border-radius: var(--r-md);
padding: 10px 18px;
cursor: pointer;
transition: background 0.15s ease;
}
.ghost:hover {
background: #e3e6ff;
}
.ghost:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.3);
}
/* ── Toast ── */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 140%);
background: var(--ink);
color: var(--white);
font-size: 13.5px;
font-weight: 500;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--sh-2);
opacity: 0;
transition:
transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1),
opacity 0.3s ease;
pointer-events: none;
z-index: 50;
}
.toast.is-show {
transform: translate(-50%, 0);
opacity: 1;
}
.toast.is-error {
background: var(--danger);
}
/* ── Responsive ── */
@media (max-width: 520px) {
.page {
padding: 18px 14px;
align-items: start;
}
.card {
padding: 24px 20px 22px;
border-radius: var(--r-md);
}
.card__title {
font-size: 21px;
}
.rules {
gap: 5px 12px;
}
.toast {
left: 14px;
right: 14px;
bottom: 14px;
transform: translateY(140%);
text-align: center;
}
.toast.is-show {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}(() => {
"use strict";
const form = document.getElementById("signup");
const submitBtn = document.getElementById("submit");
const done = document.getElementById("done");
const doneEmail = document.getElementById("done-email");
const resetBtn = document.getElementById("reset");
const toastEl = document.getElementById("toast");
/* ── Toast helper ── */
let toastTimer = null;
function toast(msg, isError = false) {
toastEl.textContent = msg;
toastEl.classList.toggle("is-error", isError);
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("is-show"), 2600);
}
/* ── Field registry ──
Each validator returns "" when valid, else a specific error message. */
const fields = {
name: {
el: form.elements.name,
help: document.getElementById("name-help"),
defaultHelp: "Use the name on your ID. At least 2 characters.",
validate(v) {
const t = v.trim();
if (!t) return "Please enter your full name.";
if (t.length < 2) return "Name is too short.";
if (!/[a-zA-ZÀ-ɏ]/.test(t)) return "Use letters for your name.";
return "";
},
},
email: {
el: form.elements.email,
help: document.getElementById("email-help"),
defaultHelp: "We'll send a confirmation link here.",
validate(v) {
const t = v.trim();
if (!t) return "Email is required.";
// Pragmatic email shape: [email protected]
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(t)) return "Enter a valid email, like [email protected].";
return "";
},
okText: "Looks good.",
},
password: {
el: form.elements.password,
help: null, // uses rules + meter instead of single help line
validate(v) {
if (!v) return "Create a password.";
if (v.length < 8) return "Use at least 8 characters.";
if (!/[a-z]/.test(v) || !/[A-Z]/.test(v)) return "Mix upper and lowercase letters.";
if (!/\d/.test(v)) return "Include at least one number.";
return "";
},
},
confirm: {
el: form.elements.confirm,
help: document.getElementById("confirm-help"),
defaultHelp: "Type it again so we know it matches.",
validate(v) {
if (!v) return "Please confirm your password.";
if (v !== fields.password.el.value) return "Passwords don't match.";
return "";
},
okText: "Passwords match.",
},
phone: {
el: form.elements.phone,
help: document.getElementById("phone-help"),
defaultHelp: "US format. At least 10 digits.",
validate(v) {
const digits = v.replace(/\D/g, "");
if (!digits) return "Phone number is required.";
if (digits.length < 10) return "Enter at least 10 digits.";
if (digits.length > 11) return "That's too many digits.";
if (digits.length === 11 && digits[0] !== "1") return "11-digit numbers must start with 1.";
return "";
},
okText: "Valid number.",
},
};
// Track which fields have been "touched" (blurred at least once).
const touched = new Set();
const debounceTimers = {};
/* ── Apply a field's visual + ARIA state ── */
function render(key) {
const f = fields[key];
const wrap = f.el.closest(".field");
const error = f.validate(f.el.value);
if (error) {
wrap.classList.add("is-error");
wrap.classList.remove("is-valid");
f.el.setAttribute("aria-invalid", "true");
if (f.help) {
f.help.textContent = error;
f.help.classList.add("is-error");
f.help.classList.remove("is-ok");
}
} else {
wrap.classList.remove("is-error");
wrap.classList.add("is-valid");
f.el.setAttribute("aria-invalid", "false");
if (f.help) {
f.help.textContent = f.okText || f.defaultHelp;
f.help.classList.toggle("is-ok", Boolean(f.okText));
f.help.classList.remove("is-error");
}
}
return !error;
}
/* Reset a field's display to neutral (used before first touch). */
function clearState(key) {
const f = fields[key];
const wrap = f.el.closest(".field");
wrap.classList.remove("is-error", "is-valid");
f.el.setAttribute("aria-invalid", "false");
if (f.help) {
f.help.textContent = f.defaultHelp;
f.help.classList.remove("is-error", "is-ok");
}
}
/* ── Password strength + rules ── */
const meter = document.querySelector("[data-meter]");
const meterBar = document.querySelector("[data-meter-bar]");
const meterLabel = document.querySelector("[data-meter-label]");
const ruleEls = {
len: document.querySelector('[data-rule="len"]'),
case: document.querySelector('[data-rule="case"]'),
num: document.querySelector('[data-rule="num"]'),
};
const STRENGTH = ["Weak", "Weak", "Fair", "Good", "Strong"];
function renderPassword() {
const v = fields.password.el.value;
const met = {
len: v.length >= 8,
case: /[a-z]/.test(v) && /[A-Z]/.test(v),
num: /\d/.test(v),
};
let score = 0;
Object.keys(met).forEach((k) => {
ruleEls[k].classList.toggle("is-met", met[k]);
if (met[k]) score++;
});
// Bonus point for length >= 12 once base rules pass.
if (score === 3 && v.length >= 12) score = 4;
const pct = v ? Math.max(8, (score / 4) * 100) : 0;
meterBar.style.width = pct + "%";
meter.setAttribute("data-level", String(Math.min(score, 4)));
meterLabel.textContent = v ? STRENGTH[Math.min(score, 4)] : "Strength";
}
/* ── Overall form gate ── */
function refreshSubmit() {
const allValid = Object.keys(fields).every((k) => fields[k].validate(fields[k].el.value) === "");
submitBtn.disabled = !allValid;
return allValid;
}
/* ── Wire up each field ── */
Object.keys(fields).forEach((key) => {
const f = fields[key];
// First blur marks the field touched and shows its state.
f.el.addEventListener("blur", () => {
touched.add(key);
render(key);
refreshSubmit();
});
// As-you-type, but only after first blur. Debounced so messages
// don't flicker on every keystroke.
f.el.addEventListener("input", () => {
if (key === "password") renderPassword();
// The confirm field depends on password — re-check it live.
if (key === "password" && touched.has("confirm")) {
clearTimeout(debounceTimers.confirm);
debounceTimers.confirm = setTimeout(() => render("confirm"), 220);
}
if (touched.has(key)) {
clearTimeout(debounceTimers[key]);
debounceTimers[key] = setTimeout(() => {
render(key);
refreshSubmit();
}, 240);
} else {
// Not yet touched: still update the submit gate quietly.
refreshSubmit();
}
});
clearState(key);
});
/* ── Phone live formatting (light touch) ── */
fields.phone.el.addEventListener("input", (e) => {
const digits = e.target.value.replace(/\D/g, "").slice(0, 11);
let local = digits;
let prefix = "";
if (digits.length === 11) {
prefix = "1 ";
local = digits.slice(1);
}
let out = local;
if (local.length > 6) out = `(${local.slice(0, 3)}) ${local.slice(3, 6)}-${local.slice(6)}`;
else if (local.length > 3) out = `(${local.slice(0, 3)}) ${local.slice(3)}`;
else if (local.length > 0) out = `(${local}`;
e.target.value = (prefix + out).trim();
});
/* ── Password reveal toggles ── */
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const input = document.getElementById(btn.getAttribute("data-toggle"));
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.setAttribute("aria-pressed", String(show));
btn.setAttribute("aria-label", show ? "Hide password" : "Show password");
input.focus();
});
});
/* ── Submit ── */
form.addEventListener("submit", (e) => {
e.preventDefault();
// Touch and validate everything; focus the first invalid field.
let firstInvalid = null;
Object.keys(fields).forEach((key) => {
touched.add(key);
const ok = render(key);
if (!ok && !firstInvalid) firstInvalid = fields[key].el;
});
renderPassword();
if (!refreshSubmit()) {
if (firstInvalid) firstInvalid.focus();
toast("Please fix the highlighted fields.", true);
return;
}
// Simulate an async account creation request.
submitBtn.classList.add("is-loading");
submitBtn.disabled = true;
submitBtn.querySelector(".submit__label").textContent = "Creating…";
setTimeout(() => {
const email = fields.email.el.value.trim();
doneEmail.textContent = email || "your inbox";
done.hidden = false;
done.querySelector(".done__title").focus?.();
toast("Account created.");
}, 900);
});
/* ── Start over ── */
resetBtn.addEventListener("click", () => {
form.reset();
touched.clear();
submitBtn.classList.remove("is-loading");
submitBtn.querySelector(".submit__label").textContent = "Create account";
submitBtn.disabled = true;
Object.keys(fields).forEach(clearState);
renderPassword();
done.hidden = true;
fields.name.el.focus();
});
// Initial paint.
renderPassword();
refreshSubmit();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Real-time inline validation</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<section class="card" aria-labelledby="form-title">
<header class="card__head">
<span class="badge">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="currentColor"
d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
/>
</svg>
Live checks
</span>
<h1 id="form-title" class="card__title">Create your account</h1>
<p class="card__sub">
Fields are checked as you go. We only show errors once you've touched a field.
</p>
</header>
<form id="signup" class="form" novalidate>
<!-- Full name -->
<div class="field" data-field="name">
<label class="field__label" for="name">
Full name <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="name"
name="name"
type="text"
class="input"
autocomplete="name"
placeholder="Mara Delacroix"
required
aria-required="true"
aria-describedby="name-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="name-help" class="help">Use the name on your ID. At least 2 characters.</p>
</div>
<!-- Email -->
<div class="field" data-field="email">
<label class="field__label" for="email">
Email address <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="email"
name="email"
type="email"
class="input"
autocomplete="email"
inputmode="email"
placeholder="[email protected]"
required
aria-required="true"
aria-describedby="email-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="email-help" class="help">We'll send a confirmation link here.</p>
</div>
<!-- Password -->
<div class="field" data-field="password">
<label class="field__label" for="password">
Password <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="password"
name="password"
type="password"
class="input"
autocomplete="new-password"
placeholder="At least 8 characters"
required
aria-required="true"
aria-describedby="password-help"
/>
<button
type="button"
class="reveal"
data-toggle="password"
aria-pressed="false"
aria-label="Show password"
>
<svg class="reveal__on" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
fill="currentColor"
d="M12 5c-5 0-9.27 3.11-11 7.5C2.73 16.89 7 20 12 20s9.27-3.11 11-7.5C21.27 8.11 17 5 12 5m0 12.5a5 5 0 1 1 0-10 5 5 0 0 1 0 10m0-8a3 3 0 1 0 0 6 3 3 0 0 0 0-6"
/>
</svg>
<svg class="reveal__off" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
fill="currentColor"
d="M2 4.27 3.28 3 21 20.72 19.73 22l-3.2-3.2A11.7 11.7 0 0 1 12 20c-5 0-9.27-3.11-11-7.5a13 13 0 0 1 4.06-5.17zm5.36 5.36A5 5 0 0 0 12 17a5 5 0 0 0 1.32-.18l-1.6-1.6A3 3 0 0 1 9 12.27zM12 5c5 0 9.27 3.11 11 7.5a13 13 0 0 1-2.43 3.5l-2.06-2.06A5 5 0 0 0 12 7c-.41 0-.82.05-1.2.15L9.2 5.55C10.1 5.2 11.04 5 12 5"
/>
</svg>
</button>
</div>
<div class="meter" data-meter>
<div class="meter__track">
<span class="meter__bar" data-meter-bar style="width: 0%"></span>
</div>
<span class="meter__label" data-meter-label>Strength</span>
</div>
<ul id="password-help" class="rules" aria-label="Password requirements">
<li class="rule" data-rule="len">
<span class="rule__dot" aria-hidden="true"></span>8+ characters
</li>
<li class="rule" data-rule="case">
<span class="rule__dot" aria-hidden="true"></span>Upper & lowercase
</li>
<li class="rule" data-rule="num">
<span class="rule__dot" aria-hidden="true"></span>A number
</li>
</ul>
</div>
<!-- Confirm password -->
<div class="field" data-field="confirm">
<label class="field__label" for="confirm">
Confirm password <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="confirm"
name="confirm"
type="password"
class="input"
autocomplete="new-password"
placeholder="Re-enter your password"
required
aria-required="true"
aria-describedby="confirm-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="confirm-help" class="help">Type it again so we know it matches.</p>
</div>
<!-- Phone -->
<div class="field" data-field="phone">
<label class="field__label" for="phone">
Phone <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="phone"
name="phone"
type="tel"
class="input"
autocomplete="tel"
inputmode="tel"
placeholder="(415) 555-0182"
required
aria-required="true"
aria-describedby="phone-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="phone-help" class="help">US format. At least 10 digits.</p>
</div>
<button id="submit" type="submit" class="submit" disabled>
<span class="submit__label">Create account</span>
<span class="submit__spin" aria-hidden="true"></span>
</button>
<p class="form__foot">
By continuing you agree to the
<a href="#" class="link">Terms</a> and
<a href="#" class="link">Privacy Policy</a>.
</p>
</form>
<!-- Success overlay -->
<div id="done" class="done" hidden>
<div class="done__seal" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30">
<path
fill="currentColor"
d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
/>
</svg>
</div>
<h2 class="done__title" tabindex="-1">You're all set</h2>
<p class="done__text">
A confirmation link is on its way to <strong id="done-email">your inbox</strong>.
</p>
<button type="button" class="ghost" id="reset">Create another account</button>
</div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Real-time inline validation
A five-field signup form (full name, email, password, confirm password, phone) that gives feedback exactly when it helps. Each field stays quiet until you leave it for the first time; after that it re-validates live as you type, debounced so the error text never jitters mid-keystroke. Valid fields earn a green checkmark, invalid ones get a red border, an icon, aria-invalid="true", and a specific message wired through aria-describedby — for example “Enter a valid email, like [email protected].” or “Passwords don’t match.”
The password field adds a strength meter and a live requirements checklist (length, mixed case, a number) that tick off as you satisfy them, plus a show/hide toggle. The phone field auto-formats US numbers as (415) 555-0182 while you type. The confirm field re-checks itself whenever the password changes, so the two never drift out of sync.
The “Create account” button is disabled until every field passes, and submitting runs a real validation pass that focuses the first invalid field if you try early. A successful submit shows a short loading state and then an animated confirmation panel addressed to the email you entered, with a “Create another account” action that resets everything. All status changes are announced through an aria-live toast, and the flow is fully keyboard-operable with visible focus rings.