DEV Community

Cover image for CSS Just Got a Parent Selector. Your Forms Will Never Look the Same
Artclick
Artclick

Posted on

CSS Just Got a Parent Selector. Your Forms Will Never Look the Same

For as long as I've been writing CSS, there's been one direction it refused to look: up. You could style a child based on its parent all day long, but the second you wanted a parent to react to something happening inside it — a checked checkbox, an invalid field, a filled-in input — you were reaching for JavaScript. Every time. It didn't matter how small the interaction was.

:has() breaks that rule on purpose, and it's been safe to use in production for a while now — it's supported across Chrome, Edge, Firefox, Safari, and Opera, no polyfill required. I didn't fully appreciate what that meant until I rebuilt a form I'd been maintaining for two years and deleted most of the JavaScript in it. Not all of it — I'll get to where it still earns its place — but most.

The rule CSS used to have

/* This has always worked: style a child based on the parent */
.card.featured .title { color: gold; }

/* This has never worked, until :has(): style the parent based on a child */
.card:has(.badge--sold-out) { opacity: 0.6; }
Enter fullscreen mode Exit fullscreen mode

:has() reads as "select this element, if it contains a match for whatever's inside the parentheses." Once that clicks, a huge category of things people were writing classList.toggle() calls for turns into a single selector.

Styling a label when its input is focused

This used to mean a focus and blur listener on the input, toggling a class on the label. Now:

.field:has(input:focus) {
  border-color: var(--accent-color);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 25%, transparent);
}
Enter fullscreen mode Exit fullscreen mode

Wrap the label and input in a .field container, and the whole field lights up the moment the input inside it gets focus — no listener, no class toggle, and it can never drift out of sync with the actual focus state, because it is the actual focus state.

Required-field indicators that can't go stale

I've fixed this bug more times than I want to admit: a form gets a field added, and someone forgets to also add the little red asterisk that's supposed to mark it required. With :has(), the asterisk isn't a separate thing you remember to add — it's derived directly from the required attribute:

.field:has(input:required) .field-label::after {
  content: " *";
  color: var(--error-color);
}
Enter fullscreen mode Exit fullscreen mode

Add required to the input, the asterisk appears. Remove it, the asterisk disappears. There's no second place to update, so there's no way for the two to disagree.

Validation styling without a single event listener

This is the one that actually made me sit up. Native HTML validation states — :valid, :invalid — have existed forever, but styling the field wrapper based on the input's validity used to be impossible without JS, because the wrapper isn't the thing that's valid or invalid, the input is.

.field:has(input:invalid:not(:placeholder-shown)) {
  border-color: var(--error-color);
}

.field:has(input:invalid:not(:placeholder-shown)) .field-error {
  display: block;
}

.field:has(input:valid) .field-success-icon {
  display: inline;
}
Enter fullscreen mode Exit fullscreen mode

The :not(:placeholder-shown) part matters more than it looks like it should — without it, every empty required field shows as an error the instant the page loads, before the person has even had a chance to type anything. Pairing it with :invalid means the error only shows once someone's actually interacted with the field and left it wrong.

A submit button that enables itself

<form>
  <input type="email" required>
  <input type="password" required minlength="8">
  <button type="submit">Create account</button>
</form>
Enter fullscreen mode Exit fullscreen mode
form:has(input:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}
Enter fullscreen mode Exit fullscreen mode

The button disables itself as long as anything in the form is invalid, and re-enables the instant everything passes native validation. I want to flag something here though, because it's the kind of detail that's easy to skip past: pointer-events: none blocks clicks, but it doesn't stop a screen reader user from tabbing to the button and trying to activate it, and it gives no explanation for why nothing happened. If accessibility matters for your form — and it should — pair this with the actual disabled attribute set via a tiny bit of JS, or at minimum make sure your fields' own validation messages are what's actually communicating the problem, not the button's disabled-looking state.

Counting checked items and styling accordingly

.filter-panel:has(input[type="checkbox"]:checked) .clear-button {
  display: inline-block;
}

.filter-panel:not(:has(input[type="checkbox"]:checked)) .clear-button {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

A "Clear filters" button that only appears once at least one filter checkbox is actually checked. I used to wire this up with a change listener that recounted checked boxes on every click. Now it's two selectors.

Where I'd still reach for JavaScript

I don't want to oversell this. :has() covers presentation — how things look based on state that already exists in the DOM. It doesn't cover behavior that needs to happen at a specific moment: submitting data to a server, showing a toast on success, redirecting after login, debouncing a search-as-you-type field. Those are still JavaScript's job, and no amount of clever selector nesting changes that.

What :has() actually replaced in my form wasn't the logic — it was the busywork. All the little classList.add/remove calls that existed purely to mirror state the browser already knew, just so CSS could react to it. That's the part that's gone, and it's also the part that used to drift out of sync and cause bugs nobody could reproduce reliably.

One real performance caveat

:has() has to check the subtree underneath the element it's evaluating, which means a selector like body:has(.some-rare-class) forces the browser to search a huge chunk of the page for every state change. Keep the element you're calling :has() on as narrow as possible — a .field wrapper around one input, not the whole form, and definitely not the whole page — and keep what's inside the parentheses specific. .field:has(input:invalid) is cheap. main:has(.error) on a content-heavy page is the kind of thing that can genuinely show up in a performance profile.

Try it on something small first

If you haven't used :has() yet, don't start by rewriting an entire form system. Pick one spot where you're currently toggling a class purely to mirror state the DOM already has — a focused input, a checked box, a filled field — and swap it for a :has() selector. It's a small change, but it's the kind that quietly removes a whole category of "why did this get out of sync" bugs from your codebase.


We're ArtClick, a web development agency based in Kyoto. We build company websites, WordPress sites, and custom systems — with a focus on sites that are fast, well-designed, and easy to maintain long-term. Learn more at artclickdev

Top comments (0)