Form Validation
Patterns for surfacing errors and confirmations in forms. Validate on blur, not on every keystroke — give users room to type before judging. Show errors inline, close to the field, using plain language.
Preview
Tab away from the email field to trigger on-blur validation. Type in the password field to see the strength meter. Submit with an invalid state to see submit-time errors.
When to validate
| Trigger | Use for | Avoid |
|---|---|---|
| On blur (focus leave) | Email, URL, date, any field where the full value matters | Password strength — show that eagerly after the first character |
| On input (keystroke) | Password strength meter, character counter, live search | Error messages — premature errors on partial input are frustrating |
| On submit | Final gate; catches any field the user skipped | As the only validation trigger — users have already committed before they see errors |
| Server-side async | Unique username/email check, promo code validation | Firing too eagerly; debounce by ≥ 400 ms and only trigger after on-blur |
Error message guidance
- →Be specific. "Enter a valid email address" is better than "Invalid input". Tell the user exactly what's wrong and how to fix it.
- →Plain language, no jargon. Avoid "regex mismatch", "null value", or HTTP status codes. Write as if speaking to the user directly: "Email can't be blank."
- →Inline, not modal. Show errors close to the field they describe, not in a dialog or at the top of the page. Users shouldn't have to hunt for which field failed.
- →Don't clear errors on focus. Clear the error when the user starts correcting the value (on input), not when they simply focus the field. Premature clearing makes users wonder if they've already fixed it.
- →Pair errors with positive states. When a field is valid, show a green check (or similar). This confirms to the user that this field is done — they don't need to guess.
Implementation
ValidationDemo.tsx
tsx
import { useState } from "react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { AlertCircle, Check } from "lucide-react";
import { cn } from "@/lib";
function isValidEmail(v: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
}
function getPasswordStrength(pw: string) {
let score = 0;
if (pw.length >= 8) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
if (score <= 1) return { score, label: "Weak", color: "bg-red-500" };
if (score <= 2) return { score, label: "Fair", color: "bg-amber-500" };
return { score, label: "Strong", color: "bg-green-500" };
}
export function SignUpForm() {
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [emailValid, setEmailValid] = useState(false);
const [password, setPassword] = useState("");
const strength = getPasswordStrength(password);
// Validate on blur — not on every keystroke
function handleEmailBlur() {
if (!email) return setEmailError("Email is required.");
if (!isValidEmail(email)) return setEmailError("Enter a valid email address.");
setEmailError("");
setEmailValid(true);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Final validation on submit
if (!isValidEmail(email)) return setEmailError("Enter a valid email address.");
if (strength.score < 2) return; // show inline strength meter
// proceed...
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4 max-w-sm">
{/* Email with on-blur validation */}
<div className="space-y-1">
<label className="block text-[12px] font-medium text-text-secondary">Email</label>
<Input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setEmailError(""); }}
onBlur={handleEmailBlur}
placeholder="you@example.com"
error={emailError || undefined}
rightIcon={emailValid ? <Check className="w-4 h-4 text-green-400" /> : undefined}
/>
{emailError && (
<p className="flex items-center gap-1 text-[11px] text-red-400">
<AlertCircle className="w-3 h-3" /> {emailError}
</p>
)}
</div>
{/* Password with strength meter */}
<div className="space-y-1">
<label className="block text-[12px] font-medium text-text-secondary">Password</label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password"
/>
{password && (
<div className="space-y-1">
<div className="flex gap-1">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
i <= strength.score ? strength.color : "bg-border"
)}
/>
))}
</div>
<p className="text-[11px] font-medium">{strength.label}</p>
</div>
)}
</div>
<Button type="submit" className="w-full">Create account</Button>
</form>
);
}Accessibility
- →Link error messages to their inputs with aria-describedby so screen readers announce the error when the field receives focus.
- →Mark invalid fields with aria-invalid="true" when they have an error. Remove the attribute (or set to false) once the error is cleared.
- →On submit failure, move focus to the first invalid field. Don't leave focus on the submit button — users with screen readers may not discover which field failed.
- →Never rely on color alone to communicate an error. Pair the red color with an icon (AlertCircle) and text so colorblind users can identify the error state.
- →The password strength meter should have an aria-live="polite" region so the label (Weak/Fair/Strong) is announced as the user types.
- →noValidate on the <form> element disables browser-native validation bubbles in favor of your custom inline errors. Do this whenever you implement custom validation.