Form Validation States and User Feedback
Validation states are critical UX — users need immediate, clear feedback when their input is correct or incorrect. CSS provides powerful pseudo-classes for native validation, supplemented by JS-applied classes for custom rules.
1. Native CSS Validation Pseudo-Classes
| State | Pseudo-Class | When Active |
|---|---|---|
| Input passes validation | :valid | Required + not empty; email format OK; number in range |
| Input fails validation | :invalid | Required + empty; bad email; number out of range |
Value within min/max | :in-range | Number type with min/max |
Value outside min/max | :out-of-range | |
| Field is required | :required | Has required attribute |
| Field is optional | :optional | No required attribute |
| Placeholder showing | :placeholder-shown | Field is empty (placeholder visible) |
| Value matches pattern | :user-valid ⚡ | Valid after user interacted (modern) |
| Value doesn't match | :user-invalid ⚡ | Invalid after user interacted (modern) |
[!NOTE] Use
:user-validand:user-invalid(newer) instead of:valid/:invalidwhenever possible — they only apply after the user has interacted with the field, preventing the jarring red border on page load before the user has typed anything.
2. The :user-valid / :user-invalid Pattern (Modern)
/* Only show validation state after user interaction */
.form-input:user-valid {
border-color: var(--color-success, #38a169);
box-shadow: 0 0 0 3px rgba(56, 161, 105, 0.2);
}
.form-input:user-invalid {
border-color: var(--color-danger, #e53e3e);
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.2);
}
/* Fallback for browsers without :user-valid support */
@supports not selector(:user-valid) {
/* Apply class with JS after first change event */
.form-input.is-touched:valid { border-color: var(--color-success); }
.form-input.is-touched:invalid { border-color: var(--color-danger); }
}
3. Complete Validation State Styles
/* ── Input states ── */
.form-input:user-valid,
.form-input.is-valid {
border-color: var(--color-success, #38a169);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2338a169'%3E%3Cpath d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.875rem center;
background-size: 1.25rem;
padding-right: 2.5rem;
}
.form-input:user-invalid,
.form-input.is-invalid {
border-color: var(--color-danger, #e53e3e);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23e53e3e'%3E%3Cpath fill-rule='evenodd' d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z' clip-rule='evenodd'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.875rem center;
background-size: 1.25rem;
padding-right: 2.5rem;
}
.form-input:user-valid:focus { box-shadow: 0 0 0 3px rgba(56, 161, 105, 0.25); }
.form-input:user-invalid:focus { box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.25); }
4. Error and Success Messages
<!-- Invalid state with error message -->
<div class="form-group">
<label class="form-label" for="email">Email</label>
<input class="form-input is-invalid" type="email" id="email"
aria-describedby="email-error" aria-invalid="true">
<span class="form-error" id="email-error" role="alert">
Please enter a valid email address.
</span>
</div>
<!-- Valid state with success message -->
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input class="form-input is-valid" type="text" id="username"
aria-describedby="username-success">
<span class="form-success" id="username-success">
✓ Username is available!
</span>
</div>
/* Error message */
.form-error {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: var(--text-sm, 0.875rem);
color: var(--color-danger, #e53e3e);
margin-top: 0.25rem;
animation: shake-error 0.4s ease;
}
.form-error::before { content: '⚠ '; }
/* Success message */
.form-success {
font-size: var(--text-sm, 0.875rem);
color: var(--color-success, #38a169);
margin-top: 0.25rem;
}
/* Shake animation for errors */
@keyframes shake-error {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
5. Live Password Strength Indicator
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" class="form-input">
<div class="strength-bar">
<span class="strength-bar__track">
<span class="strength-bar__fill"></span>
</span>
<span class="strength-bar__label">Enter a password</span>
</div>
</div>
.strength-bar { margin-top: 0.5rem; }
.strength-bar__track {
display: block;
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
}
.strength-bar__fill {
display: block;
height: 100%;
width: 0%;
border-radius: 2px;
transition: width 0.4s ease, background 0.4s ease;
}
/* Strength levels — toggled via JS by adding class to .strength-bar */
.strength-bar[data-strength="weak"] .strength-bar__fill { width: 25%; background: var(--color-danger); }
.strength-bar[data-strength="fair"] .strength-bar__fill { width: 50%; background: var(--color-warning); }
.strength-bar[data-strength="good"] .strength-bar__fill { width: 75%; background: hsl(40,90%,55%); }
.strength-bar[data-strength="strong"] .strength-bar__fill { width: 100%; background: var(--color-success); }
.strength-bar__label { font-size: var(--text-sm); color: var(--color-text-muted); margin-top: 0.25rem; display: block; }
6. Required Field Indicator Pattern
/* Mark required fields automatically */
label:has(+ [required])::after,
label:has(+ [aria-required="true"])::after {
content: ' *';
color: var(--color-danger, #e53e3e);
font-weight: 400;
}
/* Or with a data attribute */
.form-label[data-required]::after {
content: ' (required)';
color: var(--color-text-muted);
font-size: 0.8em;
font-weight: 400;
}
7. Complete Form with All Validation States
/* Entire form section feedback when invalid */
.form-section:has(input:invalid:not(:placeholder-shown)) {
border-left: 3px solid var(--color-danger);
padding-left: 1rem;
}
/* ── Form submission loading state ── */
.form--loading .btn[type="submit"] {
opacity: 0.6;
pointer-events: none;
position: relative;
}
.form--loading .btn[type="submit"]::after {
content: '';
position: absolute;
right: 1rem; top: 50%;
width: 1rem; height: 1rem;
margin-top: -0.5rem;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }