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.

Five :has() patterns for forms Cards describing five ways to use the :has() selector with validity pseudo-classes: field wrappers, labels, groups, sections and the form itself. Field wrapper .field:has(:user-invalid) styles label, hint and icon together Label sibling label:has(+ input:user-invalid) when label precedes input Group fieldset:has(:user-invalid) for radios, checkboxes, date parts Section or step details:has(:user-invalid) summary gets a marker Whole form form:has(:user-invalid) shows a submit hint Negation .field:not(:has(:user-invalid)) for calm defaults
Each pattern reads validity from the fields themselves, so no script has to keep classes in sync.

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.

Validity flowing outward through :has() A field's validity, set by constraints and setCustomValidity, is read by :user-invalid on the field and then by :has() on its wrapper, group, section and form. Field validity constraints + setCustomValidity Field :user-invalid border Wrapper / label .field:has(:user-invalid) Group / section fieldset, details marker Form form:has(:user-invalid) submit hint
One source of truth — the field's validity — styles every ring around it, from the label out to the submit hint.

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.

Class toggling versus :has() for container states Two columns comparing script that toggles error classes on containers with CSS :has() rules that read field validity directly. Toggling classes in script • add .has-error on blur, submit, server error ✗ must remove on reset, edit, row removal ✗ easy to miss a path and leave stale styles ✓ works in very old browsers :has() + :user-invalid • reads each field's validity directly ✓ correct after reset, server errors, new rows ✓ no script to maintain ✗ needs a fallback for old engines
Classes must be added and removed on every path that changes validity; :has() cannot drift because it reads validity itself.

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.

← Back to CSS Validation State Styling