Skip to content

CSS :has() Selector: The JavaScript Hacks I Finally Deleted

CSS :has() Selector: The JavaScript Hacks I Finally Deleted

I spent about three years fighting the fact that CSS didn’t have a parent selector. Not gracefully. I wrote small React hooks that added .has-input classes to divs. I had a MutationObserver on a form once. Once. I regret it.

Then Firefox finally shipped :has() at the end of 2023, and I ignored it for another eight months because I assumed one browser wouldn’t be enough. Then I checked caniuse and realized I could’ve deleted that MutationObserver a year earlier.

This is a post about what I actually took out of a real Next.js app after I stopped being scared of :has(). Some of it was JavaScript I wrote myself. Some was JavaScript that came bundled with UI libraries. All of it was worse than the CSS one-liner I ended up with.

Short version for the impatient: :has() is a CSS pseudo-class that lets a parent selector care about what’s inside it. That single change makes about a dozen “clever” DOM hacks look silly.

The Parent Selector I Fake-News Solved For Years

The classic problem: you want a <label> to look different when its <input> has a value, or a card to change color when it contains an image, or a form section to highlight when a checkbox inside it is unchecked. CSS could always style children based on parents. It couldn’t go the other way.

So I did what everyone did. I added a class to the parent from JavaScript. Here’s a version I actually shipped, cleaned up a bit for this post:

function FieldGroup({ children, hasError }) {
  const [hasValue, setHasValue] = useState(false);

  return (
    <div className={`field-group ${hasValue ? 'has-value' : ''} ${hasError ? 'has-error' : ''}`}>
      {children}
      <input
        onChange={(e) => setHasValue(Boolean(e.target.value))}
      />
    </div>
  );
}
.field-group.has-value { border-color: var(--success); }
.field-group.has-error { border-color: var(--danger); }

It works. It also means the CSS can’t run without React, adds state to a component that had none, and re-renders the whole group every time the user hits a key. For a login form that isn’t a big deal. For a settings page with forty inputs, it adds up.

What :has() Actually Solves

Same thing, with :has():

.field-group:has(input:not(:placeholder-shown)) {
  border-color: var(--success);
}
.field-group:has(input[aria-invalid="true"]) {
  border-color: var(--danger);
}

No React state. No class toggling. No onChange handler that exists only to sync a DOM attribute. The browser watches the tree for me. If the input has a value, the parent picks it up. If it doesn’t, the parent goes back to default.

I deleted the useState, deleted the className concatenation, and deleted the onChange handler. The component got twelve lines shorter and about 40% faster to render on my slower test devices.

You can read the spec on MDN’s :has() page, which is unusually clear. The interesting part: the specificity of :has(X) is the specificity of X. That bit me later.

Six Things I Actually Deleted After Turning It On

The deletions were the fun part. In one sprint I removed:

  1. A useEffect that added .has-image to a card when a child <img> finished loading. Now: .card:has(img) { ... }.
  2. A MutationObserver that watched a comments thread for reply forms and highlighted the parent. Now: .thread:has(form) { ... }.
  3. Three variants of close buttons that changed color when the modal had unsaved changes. Now: .modal:has([data-dirty="true"]) .close-btn { ... }.
  4. A data-empty="true" attribute I set from an effect on empty lists. Now: .list:not(:has(li)) { ... }.
  5. Two tab components (one from a library, one home-rolled) that used JS to color the active parent. Now: [role="tablist"]:has([aria-selected="true"]) { ... }.
  6. A hack where a form disabled its own submit button by adding .form-invalid from React. Now: form:has(:invalid) button[type="submit"] { opacity: 0.5; pointer-events: none; }.

Total lines removed: 217. I did not measure this to brag. I measured it because my code review buddy wouldn’t believe me.

What :has() Actually Is (And Why It Took So Long)

:has() is a functional CSS pseudo-class. It matches an element if any of its descendants match the selector you pass in. That’s it.

/* any article that contains at least one <video> */
article:has(video) { ... }

/* combine with :not() for "articles with no video" */
article:not(:has(video)) { ... }

/* nest if you have to */
main:has(article:has(video)) { ... }

The reason it waited so long is honest: parent selectors are expensive. Every change inside an element potentially invalidates the parent’s style. The browser vendors were nervous about pages with tens of thousands of DOM nodes suddenly getting :has() and grinding to a crawl.

That fear turned out to be mostly overblown. Chrome’s engineers wrote a good post on why it’s fast enough that walks through the invalidation cache they added. On the 40,000-node monster page I stress-tested it on, style recalc went from “instant” to “still instant, but slightly less so”. Not a real regression.

Support today: Chrome 105+, Safari 15.4+, Firefox 121+, Edge 105+. If your users are on evergreen browsers, you’re covered. If you support anything older, keep the JS fallback for the few percent that need it.

The Gotchas I Still Hit

Three of them keep catching me.

Specificity is the child, not the parent. .card:has(.error) has the specificity of .card plus .error, not just .card. That means it can win fights you didn’t expect it to win. When I rewrote our error-state CSS, one of my .card.card--muted rules stopped applying because .card:has(.error) beat it. Fix: either bump the loser’s specificity or restructure the selector.

It doesn’t do form validation for you. :has(:invalid) matches when a form is invalid. It doesn’t tell you what’s invalid. For real error messaging you still need JavaScript. :has() is for styling, not for state.

Server-rendered classes still matter for FOUC. If you’re relying on :has() for a “loaded” state on hydration, you’ll get a visual flash before the browser paints. In one Next.js app I had to keep a data-hydrated attribute for that first paint. Small caveat, not a dealbreaker.

I wrote about a related pattern in my post on Tailwind v4 container queries, where the same theme applies: CSS learned to do things we used to fake in JS, and the deletions add up.

Where I Use It And Where I Don’t

:has() isn’t a hammer for every parent-styling need. My rough rules:

  • Use it for pure styling. Border color, spacing, icon visibility, hover interactions. Anything the CSS engine can decide on its own.
  • Don’t use it for state. If you need to do something when a child changes (call an API, set focus, dispatch an event), you still need a handler. :has() won’t fire callbacks.
  • Don’t use it to reach across huge trees. body:has(.dropdown-open) main is legal. It’s also going to make Chrome sad if .dropdown-open toggles a hundred times a second. Scope narrowly.
  • Check specificity when replacing existing CSS. More than half my :has() migrations broke something else the first time I tried them. Not because :has() is wrong. Because I hadn’t reasoned about specificity for a decade.

I keep a small comment above every :has() rule now: /* replaces JS: <thing> */. Helps future me not undo it.

One Thing To Try This Week

Pick one component in your codebase that toggles a class on a parent from JavaScript. Look at the class. Ask whether the browser could tell it that same information by looking at the tree. If yes, delete the handler, write the selector, and see what breaks. Worst case, you learn where your specificity graveyard is buried. Best case, you delete a state hook you never wanted.

If you find one where it wasn’t worth switching, I’d like to hear about it. I keep a running list of anti-patterns for my work notes, and honest counter-examples are more useful than another success story.