UI Components Easy
Inline Validation
Validate fields as they blur and summarize the form state without waiting for submit.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--text: #eef2ff;
--muted: #9eb1d4;
--ok: #3ddc97;
--err: #ff6b7a;
--accent: #78a9ff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(120% 90% at 50% 0%, #16203a 0%, var(--bg) 60%);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
button, input { font: inherit; }
.demo {
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
}
.iv-form {
width: min(440px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
padding: clamp(1.25rem, 4vw, 2rem);
box-shadow: 0 24px 70px #0006;
}
.iv-head h1 { margin: 0 0 .35rem; font-size: 1.35rem; letter-spacing: -.02em; }
.iv-head p { margin: 0 0 1.4rem; color: var(--muted); font-size: .875rem; }
.field { position: relative; margin-bottom: 1rem; }
.field label {
display: block;
margin-bottom: .35rem;
font-size: .78rem;
font-weight: 600;
letter-spacing: .02em;
color: var(--muted);
}
.field input {
width: 100%;
padding: .7rem 2.4rem .7rem .8rem;
background: #0e1526;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--text);
transition: border-color .18s ease, box-shadow .18s ease;
}
.field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px #78a9ff2e;
}
/* status dot, pure CSS — no assets */
.field::after {
content: "";
position: absolute;
right: .85rem;
top: 2.45rem;
width: 12px;
height: 12px;
border-radius: 50%;
opacity: 0;
transform: scale(.5);
transition: opacity .18s ease, transform .18s ease;
}
.field[data-state="valid"]::after,
.field[data-state="invalid"]::after { opacity: 1; transform: scale(1); }
.field[data-state="valid"]::after { background: var(--ok); }
.field[data-state="invalid"]::after { background: var(--err); }
.field[data-state="valid"] input { border-color: #3ddc9788; }
.field[data-state="invalid"] input { border-color: var(--err); }
.field[data-state="invalid"] input:not(:focus) { animation: nudge .22s ease; }
@keyframes nudge {
0%, 100% { transform: translateX(0); }
30% { transform: translateX(-4px); }
70% { transform: translateX(4px); }
}
.msg {
margin: .35rem 0 0;
min-height: 1.1em;
font-size: .78rem;
color: var(--muted);
}
.field[data-state="invalid"] .msg { color: var(--err); }
.field[data-state="valid"] .msg { color: var(--ok); }
.summary {
margin: 1.1rem 0;
padding: .75rem .9rem;
border: 1px solid #ff6b7a66;
background: #ff6b7a14;
border-radius: 12px;
font-size: .82rem;
}
.summary:focus-visible { outline: 2px solid var(--err); outline-offset: 2px; }
.summary ul { margin: .5rem 0 0; padding-left: 1.1rem; }
.summary a { color: var(--err); }
.iv-foot { margin-top: 1.4rem; }
.meter { height: .3rem; border-radius: 99px; background: var(--line); overflow: hidden; }
.meter span {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent), var(--ok));
transition: width .3s ease;
}
.count { margin: .5rem 0 .9rem; font-size: .78rem; color: var(--muted); }
button[data-submit] {
width: 100%;
padding: .75rem;
border: 0;
border-radius: 10px;
background: var(--accent);
color: #0b1020;
font-weight: 600;
cursor: pointer;
transition: filter .18s ease, background .2s ease;
}
button[data-submit]:hover { filter: brightness(1.08); }
button[data-submit]:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
button[data-submit][data-done] { background: var(--ok); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}/* Inline Validation — validate on blur, re-validate live once touched. */
(() => {
const form = document.querySelector(".iv-form");
if (!form) return;
const summary = form.querySelector("[data-summary]");
const summaryList = form.querySelector("[data-summary-list]");
const meter = form.querySelector("[data-meter]");
const count = form.querySelector("[data-count]");
const submit = form.querySelector("[data-submit]");
const fields = [...form.querySelectorAll("[data-field]")].map((wrap) => ({
wrap,
input: wrap.querySelector("input"),
msg: wrap.querySelector(".msg"),
label: wrap.querySelector("label").textContent.trim(),
touched: false,
}));
const EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
const rules = {
required: (v) => (v.trim().length >= 2 ? null : "Enter at least 2 characters."),
email: (v) =>
!v.trim() ? "Email is required." : EMAIL.test(v.trim()) ? null : "That doesn't look like a valid email.",
phone: (v) => {
const digits = v.replace(/\D/g, "");
if (!digits) return "Phone is required.";
return digits.length >= 7 && digits.length <= 15 ? null : "Use 7 to 15 digits.";
},
password: (v) => {
if (v.length < 8) return "Use at least 8 characters.";
if (!/[a-z]/.test(v) || !/[A-Z]/.test(v)) return "Mix upper and lower case letters.";
if (!/\d/.test(v)) return "Include at least one number.";
return null;
},
match: (v, input) => {
const other = form.querySelector("#" + input.dataset.match);
if (!v) return "Confirm your password.";
return v === other.value ? null : "Passwords do not match.";
},
};
const okText = {
required: "Looks good.",
email: "Email format is valid.",
phone: "Phone number accepted.",
password: "Strong enough.",
match: "Passwords match.",
};
function validate(field, { show = true } = {}) {
const { input } = field;
const error = rules[input.dataset.rule](input.value, input);
field.error = error;
if (!show || (!field.touched && !error)) {
// keep neutral until the field has been visited
}
if (!field.touched) {
field.wrap.removeAttribute("data-state");
field.msg.textContent = "";
input.removeAttribute("aria-invalid");
} else {
field.wrap.dataset.state = error ? "invalid" : "valid";
field.msg.textContent = error || okText[input.dataset.rule];
input.setAttribute("aria-invalid", error ? "true" : "false");
}
return !error;
}
function refresh() {
const valid = fields.filter((f) => !f.error).length;
meter.style.width = (valid / fields.length) * 100 + "%";
count.textContent = `${valid} of ${fields.length} fields valid`;
}
fields.forEach((field) => {
field.input.addEventListener("blur", () => {
field.touched = true;
validate(field);
refresh();
});
field.input.addEventListener("input", () => {
// live re-validation only after the first blur (no premature yelling)
if (field.touched) validate(field);
else field.error = rules[field.input.dataset.rule](field.input.value, field.input);
// confirm depends on password
const confirm = fields.find((f) => f.input.dataset.match === field.input.id);
if (confirm && confirm.touched) validate(confirm);
refresh();
});
});
form.addEventListener("submit", (event) => {
event.preventDefault();
fields.forEach((f) => {
f.touched = true;
validate(f);
});
refresh();
const bad = fields.filter((f) => f.error);
if (bad.length) {
summaryList.replaceChildren(
...bad.map((f) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "#" + f.input.id;
a.textContent = `${f.label}: ${f.error}`;
a.addEventListener("click", (e) => {
e.preventDefault();
f.input.focus();
});
li.append(a);
return li;
})
);
summary.hidden = false;
summary.focus();
submit.removeAttribute("data-done");
submit.textContent = "Create account";
return;
}
summary.hidden = true;
submit.dataset.done = "true";
submit.textContent = "Account created";
});
fields.forEach((f) => validate(f, { show: false }));
refresh();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inline Validation</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Inline Validation">
<form class="iv-form" novalidate>
<header class="iv-head">
<h1>Create your account</h1>
<p>Fields validate on blur, then live while you correct them.</p>
</header>
<div class="field" data-field>
<label for="name">Full name</label>
<input id="name" name="name" type="text" autocomplete="name"
data-rule="required" aria-describedby="name-msg" />
<p class="msg" id="name-msg" aria-live="polite"></p>
</div>
<div class="field" data-field>
<label for="email">Email</label>
<input id="email" name="email" type="email" inputmode="email" autocomplete="email"
data-rule="email" aria-describedby="email-msg" />
<p class="msg" id="email-msg" aria-live="polite"></p>
</div>
<div class="field" data-field>
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel"
data-rule="phone" aria-describedby="phone-msg" />
<p class="msg" id="phone-msg" aria-live="polite"></p>
</div>
<div class="field" data-field>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password"
data-rule="password" aria-describedby="password-msg" />
<p class="msg" id="password-msg" aria-live="polite"></p>
</div>
<div class="field" data-field>
<label for="confirm">Confirm password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password"
data-rule="match" data-match="password" aria-describedby="confirm-msg" />
<p class="msg" id="confirm-msg" aria-live="polite"></p>
</div>
<div class="summary" data-summary hidden tabindex="-1" role="alert">
<strong>Fix these before continuing</strong>
<ul data-summary-list></ul>
</div>
<footer class="iv-foot">
<div class="meter" aria-hidden="true"><span data-meter></span></div>
<p class="count" data-count aria-live="polite">0 of 5 fields valid</p>
<button type="submit" data-submit>Create account</button>
</footer>
</form>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function FormInlineValidation() {
const [value, setValue] = useState("");
return (
<form className="demo" onSubmit={(event) => event.preventDefault()}>
<h2>Inline Validation</h2>
<label>
{value.length ? "Ready" : "Enter a value"}
<input value={value} onChange={(event) => setValue(event.target.value)} />
</label>
<button type="submit">Continue</button>
</form>
);
}Inline Validation
Validate fields as they blur and summarize the form state without waiting for submit.
Support notes
Validation is local and progressively enhanced. Keep labels, live messages, and inputmode attributes intact when adapting these patterns to a real backend.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.