Ditching JavaScript for CSS :has() in Complex UIs

I have a confession – I used to hate Docker. But I’ve been thinking a lot about this lately, and I have to admit, deleting JavaScript was easily the best part of my week. Well, that’s not entirely accurate. I was actually profiling our internal chat dashboard on Chrome 138 last Tuesday, trying to figure out why typing felt like dragging a piano through sand. And the culprit wasn’t some massive data processing pipeline. It was our prompt picker component. Every time a user focused the input, selected a model, or added a filter chip, React was triggering a full re-render of the parent wrapper just to update some background colors and spacing. Probably not the best approach.

We had built a classic imperative UI trap. Tracking isFocused, hasActiveFilter, and selectedModel just to toggle CSS classes. It was a mess. I ripped it all out.

The Death of Imperative Styling

If you are still writing event handlers just to change a parent element’s styling based on what a child is doing, you are working too hard. The CSS :has() pseudo-class has been universally supported for a while now, but I still see codebases treating it like an experimental novelty instead of the architectural shift it actually is.

Take our chat input wrapper. The design requirements were annoying but standard: the input background needs to be transparent if a model filter is active, the spacing between filter chips needs to tighten up when more than three are selected, and the whole container needs a specific glow when the inner text area is focused.

const wrapperClass = isFocused ? 'ring-2 ring-blue' : (hasFilter ? 'bg-transparent' : 'bg-gray-100');

That forces the JavaScript engine to evaluate the logic, update the virtual DOM, diff it, and patch the real DOM. For a visual state change. It’s ridiculous when you actually think about it.

CSS programming code - A closeup of html css and javascript code being written in an ide ...
CSS programming code – A closeup of html css and javascript code being written in an ide …

Here is the modern CSS equivalent:

.chat-wrapper:has(:focus-visible) { box-shadow: 0 0 0 2px blue; }
.chat-wrapper:has(.filter-chip) .input-field { background: transparent; }

The browser handles it natively. No state, no re-renders, no lifecycle hooks. It just works.

Fixing the Spacing Problem

The real revelation for me wasn’t just colors and borders. It was layout math.

We had a persistent bug with filter spacing. When users loaded up the prompt picker with multiple context tags, the UI looked cramped. The original developer wrote a useEffect that counted the number of DOM nodes inside the filter container and applied a .compact-mode class if the count exceeded three.

I ran a benchmark on our staging cluster with 3 nodes to see how bad this actually was. And with a massive list of 5,000 simulated chat messages updating in real-time, that DOM-counting script was causing a 45ms layout thrash every time a user added a tag. The main thread was choking.

JavaScript code screen - JavaScript Code screen
JavaScript code screen – JavaScript Code screen

I replaced the entire script with a single CSS block using quantity queries combined with :has():

.filter-container:has(.chip:nth-child(4)) { gap: 0.25rem; }

If the container has a fourth child, tighten the gap. The layout thrash dropped from 45ms to 3ms. The browser’s native CSS engine is simply better at querying the DOM than your framework is.

Stop Micromanaging the DOM

There is a weird psychological hurdle developers have to get over here. We got so used to JavaScript being the source of truth for everything that handing control back to CSS feels wrong somehow. You look at a complex interactive component and your brain immediately starts designing a state machine.

But don’t do it.

I’ve instituted a new rule for my team: if a state variable only exists to toggle a CSS class based on the presence, state, or quantity of a child element, the PR gets rejected. You don’t need imperative code for declarative visuals anymore.

The next time you find yourself writing onMouseEnter and onMouseLeave handlers just to show a delete button on a list item, stop. Use :has() or simple hover states. Your bundle size will shrink, your render cycles will drop, and you won’t have to debug why a React hook is firing twice when someone clicks a dropdown.

Leave the heavy lifting to the browser. It knows what it’s doing.

If you are still writing event handlers just to change a parent element’s styling based on what a child is doing, you are working too hard. The CSS :has() pseudo-class has been universally supported for a while now, but I still see codebases treating it like an experimental novelty instead of the architectural shift it actually is.

I’ve instituted a new rule for my team: if a state variable only exists to toggle a CSS class based on the presence, state, or quantity of a child element, the PR gets rejected. You don’t need imperative code for declarative visuals anymore. The next time you find yourself writing onMouseEnter and onMouseLeave handlers just to show a delete button on a list item, stop. Use :has() or simple hover states. Your bundle size will shrink, your render cycles will drop, and you won’t have to debug why a React hook is firing twice when someone clicks a dropdown.

Leave the heavy lifting to the browser. It knows what it’s doing. As the author mentions, CSS-in-JS can be a security nightmare – it’s better to leverage the browser’s native CSS engine whenever possible.

Your email address will not be published. Required fields are marked *

Zeen Social Icons