Styling Validation States with the :has() Selector
How do you turn a label red when its input is invalid, outline a whole radio group when none is chosen, mark a step in a multi-step form as “needs attention”, or show a hint next to the submit button while any field has an error — without a single line of JavaScript toggling classes? The relational pseudo-class :has() lets a parent (or a preceding sibling) match based on its descendants, and combined with :user-invalid it turns the browser’s own validity into a styling hook for any element around a field. This recipe covers the five patterns that matter for forms, the markup each needs, performance limits, fallbacks, and how to keep the visual state in step with the messages your script writes through the Constraint Validation API.
When to Use :has() for Validation Styling
Use it whenever an element other than the input should react to the input’s validity:
- Labels, hints and icons that sit outside the input element.
- Groups — radio and checkbox sets, date parts, address blocks — whose validity is spread across several controls.
- Containers — accordion sections, steps, cards — that should indicate “something inside needs attention”.
- Form-level affordances — a note near the submit button, a disabled-looking state — that summarise the form.
It is a styling tool, not a messaging tool. Error text, summaries and focus still come from script, as in the CSS validation state styling topic. :has() only makes sure everything around the message looks consistent with it, and it does so without the class bookkeeping that tends to fall out of sync after resets, server errors and dynamic rows.
Minimal Working :has() Validation Styles
<form id="booking" novalidate>
<div class="field">
<label for="name">Full name</label>
<p class="hint" id="name-hint">As it appears on your ID.</p>
<input id="name" name="name" required autocomplete="name" aria-describedby="name-hint name-err">
<p class="field-error" id="name-err" hidden></p>
</div>
<fieldset class="choice">
<legend>Room type</legend>
<label><input type="radio" name="room" value="single" required> Single</label>
<label><input type="radio" name="room" value="double"> Double</label>
<p class="field-error" id="room-err" hidden></p>
</fieldset>
<details class="section" open>
<summary>Extras</summary>
<div class="field">
<label for="guests">Extra guests</label>
<input id="guests" name="guests" type="number" min="0" max="3">
</div>
</details>
<p class="submit-hint">Some answers need attention before you can book.</p>
<button type="submit">Book room</button>
</form>
/* 1. Field wrapper: label, hint and control react together */
.field:has(:user-invalid) label { color: var(--error-text, #b91c1c); font-weight: 600; }
.field:has(:user-invalid) .hint { display: none; } /* the error replaces the hint */
.field:has(:user-invalid) :is(input, select, textarea) { border-color: var(--error-border, #b91c1c); }
/* 2. Label directly before the input (no wrapper) */
label:has(+ input:user-invalid) { color: var(--error-text, #b91c1c); }
/* 3. Group: radios share one required state */
fieldset.choice:has(:user-invalid) {
border-inline-start: 4px solid var(--error-border, #b91c1c);
padding-inline-start: 0.75rem;
}
/* 4. Section: collapsed details still signal a problem inside */
details.section:has(:user-invalid) > summary::after {
content: " — needs attention";
color: var(--error-text, #b91c1c);
}
/* 5. Form: a hint near the submit button, only while something is invalid */
.submit-hint { display: none; }
form:has(:user-invalid) .submit-hint { display: block; }
// Script still owns the words; CSS reacts to validity on its own.
const form = document.querySelector<HTMLFormElement>("#booking")!;
form.addEventListener("submit", (e) => {
for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) {
const out = document.getElementById(`${el.id || el.name}-err`);
if (out) { out.textContent = el.validity.valid ? "" : el.validationMessage; out.hidden = el.validity.valid; }
}
if (!form.reportValidity()) e.preventDefault(); // also flips :user-invalid on untouched fields
});
Pattern 4 is the one that saves the most support tickets: an error inside a collapsed section is invisible, and the user cannot understand why the form will not submit. The summary marker makes the problem visible without opening the section, which pairs with the approach in validating accordion form sections.
Selector Option Reference
| Selector | Matches | Markup requirement | Notes |
|---|---|---|---|
.field:has(:user-invalid) |
Wrapper with an invalid control | Wrapper element per field | Most flexible; order-independent |
label:has(+ input:user-invalid) |
Label immediately before its input | Label then input as siblings | Breaks if a hint sits between them |
fieldset:has(:user-invalid) |
Group containing an invalid control | Real <fieldset> |
Natural for radios and checkboxes |
details:has(:user-invalid) > summary |
Section with an invalid control | <details> sections |
Works while collapsed |
form:has(:user-invalid) |
Form with any invalid control | — | Use for hints, not for disabling submit |
:has(:focus-visible) |
Container with keyboard focus inside | — | Combine with error styles carefully |
Verification Steps
import { test, expect } from "@playwright/test";
test(":has() styles follow validity without script classes", async ({ page }) => {
await page.goto("/booking");
const hint = page.locator(".submit-hint");
await expect(hint).toBeHidden();
await page.getByRole("button", { name: "Book room" }).click();
await expect(hint).toBeVisible();
await page.getByLabel("Full name").fill("Ada Lovelace");
await page.getByLabel("Single").check();
await expect(hint).toBeHidden();
const legendColor = await page.locator("fieldset.choice").evaluate((el) => getComputedStyle(el).borderInlineStartColor);
expect(legendColor).not.toBe("rgb(185, 28, 28)");
});
Edge Cases and Failure Modes
Using :has() to disable the submit button. form:has(:invalid) button[type=submit] { pointer-events: none } looks clever and is harmful: it hides why submission is blocked, leaves keyboard submission working, and breaks the canonical flow where reportValidity() explains the problem. Show a hint instead; keep the button active, as argued in disabling submit until the form is valid.
:invalid inside :has(). .field:has(:invalid) matches on page load for every empty required field. Always pair :has() with :user-invalid for visual error states.
Sibling selectors and changing markup. label:has(+ input:user-invalid) stops working the moment a designer inserts a hint paragraph between label and input. Prefer wrapper-based patterns for design systems.
Performance on huge pages. Selectors anchored at high ancestors — body:has(input:user-invalid) — force the browser to re-check broad parts of the tree on every input. Anchor :has() on the nearest meaningful container (.field, fieldset, form).
Combining :has() With Focus and Hover States
Containers that change appearance when their field is invalid also change when the field is focused or hovered, and the combinations need an explicit order. A wrapper that is both invalid and focused should keep the error colour on the label (so the user remembers what is wrong while fixing it) but show the normal focus ring on the input (so they can see where they are typing). Write the error rules first, then :focus-within and :focus-visible rules, and test the combined state rather than each in isolation. A common mistake is .field:has(:user-invalid):focus-within label { color: inherit }, which hides the error colour exactly when the user needs it most. Keep the wrapper’s error styling stable while focused and let only the input’s ring change.
.field:has(:user-invalid) label { color: var(--error-text, #b91c1c); }
.field:focus-within :is(input, select, textarea):focus-visible {
outline: 3px solid var(--focus-ring, #1d4ed8);
outline-offset: 2px;
}
Keeping the Selectors Maintainable
Relational selectors are powerful enough to become unreadable. Keep them anchored on a small, documented set of container classes — .field, fieldset.choice, details.section, the form itself — and avoid chaining several :has() levels in one rule. Put the validation-state rules in one stylesheet section with a comment listing which container reacts to what, so a designer changing the markup knows which selectors depend on it. When a new component needs container styling, add it to the list rather than inventing a one-off selector.
Fallbacks for Browsers Without :has()
Every current engine supports :has(), but older versions in the field may not. The pattern degrades safely when the field-level :user-invalid style is written separately from the relational ones: without :has(), users still see the invalid field’s border and the error message; only the surrounding flourishes — the red label, the group border, the section marker — are missing. If those flourishes carry information (the collapsed-section marker does), add a small script fallback that sets a data attribute on the container, and guard it with @supports not selector(:has(*)) so it only runs where needed.
@supports not selector(:has(*)) {
details.section[data-has-error] > summary::after { content: " — needs attention"; color: var(--error-text, #b91c1c); }
}
if (!CSS.supports("selector(:has(*))")) {
form.addEventListener("focusout", () => {
for (const d of form.querySelectorAll<HTMLDetailsElement>("details.section")) {
d.toggleAttribute("data-has-error", !!d.querySelector("[aria-invalid='true']"));
}
});
}
Combining :has() With Custom Controls and Server Errors
Because :has() reads the browser’s validity, it automatically covers errors that did not come from native attributes. A server error applied with setCustomValidity() makes its field :user-invalid once the user has interacted or submitted, so the label, group and section styles light up with no extra code — the same mapping described in mapping server field errors to form inputs. Form-associated custom elements participate too: a <rating-input> whose internals report valueMissing makes its wrapper match .field:has(:user-invalid), which is one of the strongest arguments for building design-system controls on form-associated custom elements rather than on hidden inputs, which never match.
Frequently Asked Questions
How do I style a label when its input is invalid?
Wrap each field in a container and use .field:has(:user-invalid) label. If the label directly precedes the input with nothing in between, label:has(+ input:user-invalid) also works.
Can :has() style a radio group when no option is selected?
Yes. fieldset:has(:user-invalid) matches the group once the user has interacted or tried to submit and the required radio group is still empty.
Should I use form:has(:invalid) to disable the submit button?
No. It hides why the form cannot be submitted and conflicts with reportValidity, which explains and focuses the problem. Keep the button enabled and use :has() only for hints.
Is :has() slow for form validation styling?
Not when anchored on nearby containers like .field, fieldset or form. Avoid anchoring on body or html in very large pages.
Related Guides
- CSS Validation State Styling — the pseudo-classes and principles.
- Styling Required Field Indicators — :has(:required) for labels.
- Validating Radio Groups and Fieldsets — the group validity that fieldset styles read.
- Validating Accordion Form Sections — errors inside collapsed sections.