Home / Articles / Modern CSS /
Form validation with :has()
Show error states, success indicators, and contextual hints the moment a user interacts with a form — using only CSS constraint validation and :has().
The placeholder-shown trick
A required input with placeholder=" " (a space) starts in the :placeholder-shown state. Once the user types, the placeholder disappears. Combined with :invalid, this distinguishes "untouched and empty" from "touched and wrong."
/* Untouched: placeholder still showing — no error styling */
.field:has(input:placeholder-shown) .error-msg {
display: none;
}
/* Touched and invalid: placeholder gone, validation fails */
.field:has(input:invalid:not(:placeholder-shown)) .error-msg {
display: block;
color: oklch(0.65 0.2 25);
}
/* Touched and valid */
.field:has(input:valid:not(:placeholder-shown)) .success-icon {
opacity: 1;
}
This is the core pattern. Every rule in this article builds on these three states: untouched, invalid-after-touch, and valid-after-touch.
Styling the field wrapper
Instead of adding error classes with JavaScript, let the field wrapper respond to its input's validity state directly.
.field {
border: 2px solid oklch(0.3 0.02 260);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
/* Focus ring */
.field:has(input:focus) {
border-color: oklch(0.72 0.19 265);
box-shadow: 0 0 0 3px oklch(0.72 0.19 265 / 0.15);
}
/* Invalid after interaction */
.field:has(input:invalid:not(:placeholder-shown)) {
border-color: oklch(0.65 0.2 25);
box-shadow: 0 0 0 3px oklch(0.65 0.2 25 / 0.1);
}
/* Valid after interaction */
.field:has(input:valid:not(:placeholder-shown)) {
border-color: oklch(0.7 0.18 145);
}
The border shifts from neutral to red or green based on validity, but only after the user has interacted. No premature error states on page load.
Floating labels with validation color
Combine the floating label pattern with validity-aware colors. The label floats up on focus or when filled, and its color reflects the field's state.
.field {
position: relative;
}
.field label {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
transition: transform 0.2s, color 0.2s, font-size 0.2s;
color: oklch(0.63 0.02 260);
pointer-events: none;
}
/* Float up on focus or when filled */
.field:has(input:focus) label,
.field:has(input:not(:placeholder-shown)) label {
transform: translateY(-1.75rem) scale(0.85);
}
/* Color follows validity */
.field:has(input:focus) label {
color: oklch(0.72 0.19 265);
}
.field:has(input:valid:not(:placeholder-shown)) label {
color: oklch(0.7 0.18 145);
}
.field:has(input:invalid:not(:placeholder-shown)) label {
color: oklch(0.65 0.2 25);
}
Fieldset and form-level indicators
Scale the pattern up from individual fields to entire fieldsets and the form itself. A fieldset is valid only when all its required inputs are valid.
/* Fieldset progress indicator */
fieldset:not(:has(:required:invalid)) .fieldset-status::before {
content: "Complete";
color: oklch(0.7 0.18 145);
}
fieldset:has(:required:invalid) .fieldset-status::before {
content: "Incomplete";
color: oklch(0.63 0.02 260);
}
/* Disable submit button appearance until form is valid */
form:has(:required:invalid) [type="submit"] {
opacity: 0.5;
cursor: not-allowed;
background: oklch(0.3 0.02 260);
}
form:not(:has(:required:invalid)) [type="submit"] {
background: oklch(0.52 0.22 265);
cursor: pointer;
}
The submit button dims when any required field is invalid and lights up when the form is complete. This visual feedback guides users without JavaScript validation libraries.
Inline hint messages
Show contextual help text that changes based on the input state — a pattern that typically requires JavaScript to toggle visibility classes.
/* Default: hide all hints */
.field .hint,
.field .error-msg,
.field .success-msg {
font-size: 0.8rem;
margin-top: 0.25rem;
display: none;
}
/* Show hint on focus when untouched */
.field:has(input:focus:placeholder-shown) .hint {
display: block;
color: oklch(0.63 0.02 260);
}
/* Show error when invalid after touch */
.field:has(input:invalid:not(:placeholder-shown):not(:focus)) .error-msg {
display: block;
color: oklch(0.65 0.2 25);
}
/* Show success when valid after touch */
.field:has(input:valid:not(:placeholder-shown):not(:focus)) .success-msg {
display: block;
color: oklch(0.7 0.18 145);
}
By including :not(:focus), error and success messages only appear after the user leaves the field — avoiding distracting flicker during typing.
Password strength indicator
Use the pattern attribute with multiple hidden inputs to create a visual strength meter. Each hidden check targets a different regex pattern.
/* HTML uses data attributes for each strength level */
/* pattern="(?=.*[a-z])(?=.*[A-Z]).{8,}" for strong */
.strength-bar {
height: 4px;
border-radius: 2px;
background: oklch(0.3 0.02 260);
transition: background 0.3s, width 0.3s;
width: 33%;
}
/* Weak: any input present */
.field:has(input:not(:placeholder-shown)) .strength-bar {
background: oklch(0.65 0.2 25);
width: 33%;
}
/* Medium: valid against minlength */
.field:has(input:valid:not(:placeholder-shown)) .strength-bar {
background: oklch(0.75 0.15 85);
width: 66%;
}
/* Strong: matches full pattern */
.field:has(input[data-strong]:valid:not(:placeholder-shown)) .strength-bar {
background: oklch(0.7 0.18 145);
width: 100%;
}
This approach has limits — CSS cannot evaluate arbitrary password rules. But for simple length and pattern checks, it provides instant visual feedback with zero JavaScript.
Accessibility considerations
CSS-only validation is visual feedback. It does not replace aria-invalid, aria-describedby, or live-region announcements for screen readers. Use :has() for the visual layer, and pair it with proper ARIA attributes in your HTML.
/* The CSS handles visuals */
.field:has(input:invalid:not(:placeholder-shown)) {
border-color: oklch(0.65 0.2 25);
}
/* The HTML handles semantics */
/* <input aria-invalid="true" aria-describedby="email-error"> */
/* <span id="email-error" role="alert">Enter a valid email</span> */
/* Ensure error messages are not hidden from assistive tech */
.field .error-msg {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
width: 1px;
}
.field:has(input:invalid:not(:placeholder-shown)) .error-msg {
clip: auto;
clip-path: none;
height: auto;
position: static;
width: auto;
}
Using clip-path: inset(50%) hides the message visually while keeping it in the accessibility tree. When the field becomes invalid, the message becomes visible to everyone.