DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

CSS :has() in Production: 6 Selectors That Replaced JavaScript Across My Sites

  • Six :has() selectors deleted roughly 240 lines of JS across my sites

  • Form validation styling now uses form:has(:user-invalid), zero input listeners

  • nav:has(a[aria-current]) styles parent menus without click handlers

  • Theme toggle, empty-cart layout, and image cards all run on :has(), no JS

I deleted about 240 lines of JavaScript last month and the UI got faster, not worse. The trick was :has(), the CSS parent selector that quietly hit Baseline in late 2023 and is boringly safe in 2026. Here are the six selectors I actually shipped to raxxo.shop and three other sites, with the JS they replaced. If you want the foundational set first, I covered those in the :has() patterns that changed how I write UI. This piece is the production cut.

Form validation styling without a single input listener

The old pattern: attach an input or blur listener to every field, check validity in JS, then add or remove an is-invalid class on the wrapper so the label and helper text could turn red. On a six-field checkout that was a wall of event wiring, and it always desynced when a framework re-rendered the field.

:has() plus the :user-invalid pseudo-class does the whole thing in CSS. :user-invalid only flips after the user has actually interacted with the field, so you do not scream red at someone who just loaded the page.

Before, roughly per field:


const field = wrapper.querySelector('input');
field.addEventListener('blur', () => {
  wrapper.classList.toggle('is-invalid', !field.checkValidity());
});

Enter fullscreen mode Exit fullscreen mode

After, once, for all fields:


.field:has(input:user-invalid) {
  --field-border: #ff5470;
}
.field:has(input:user-invalid) .field__hint {
  color: #ff5470;
  opacity: 1;
}
.field:has(input:user-valid) .field__check {
  opacity: 1;
}

Enter fullscreen mode Exit fullscreen mode

That replaced 38 lines of listener code on the checkout form alone. The border, the hint text, and a little green check now react to the input's own validity state, scoped to the wrapper so nothing leaks. I still keep one tiny bit of JS for the submit button (disabling it until the whole form is valid), because :has() cannot yet style an element based on a sibling form's overall validity in a way I trust across every browser. Everything visual, though, is CSS.

Two practical notes from shipping this. First, lean on the native constraint attributes (required, type="email", pattern, minlength) so the browser does the validity math for you. :user-invalid reads straight from that, which means your visual rules and your actual form submission agree by construction, with no second source of truth to drift. Second, the reduced-motion crowd gets a cleaner experience here too, since there is no class-toggle flash when a framework re-renders the field mid-typing. I measured the checkout interaction before and after on a mid-range phone: the input-to-paint delay on each keystroke dropped because the main thread no longer runs a validity check and a class write per event. It is a small number, a few milliseconds, but it is the kind of jank you feel on a long form. If you want the broader picture on dropping JS for CSS-native behavior, see Pure CSS Animations That Replace JavaScript Libraries.

Parent-of-active-child navigation that knows where you are

Highlighting the parent menu item of the current page used to mean reading the URL in JS, looping over nav links, and toggling an active class on the right `

  • `. Every router change meant re-running it. Miss an edge case and the wrong section stays lit.

If your links already carry aria-current="page" (and they should, for screen readers), :has() lets the parent style itself:


.nav__group:has(a[aria-current="page"]) > .nav__label {
  color: var(--lime);
  font-weight: 600;
}
.nav__group:has(a[aria-current="page"]) {
  border-left: 2px solid var(--lime);
}

Enter fullscreen mode Exit fullscreen mode

No loop, no class toggling, no router hook. The accessibility attribute is the single source of truth, and the visual state follows it for free. This deleted a 22-line nav-highlighter module that I had copy-pasted into four projects, which means four fewer places to fix when the markup changes.

One thing to watch with :has() and specificity. The selector takes the specificity of its most specific argument, so .nav__group:has(a[aria-current="page"]) is heavier than a plain .nav__group. If a later, simpler rule fails to override it, that is usually why. I keep these state selectors in a dedicated cascade layer so the ordering stays predictable and I am not fighting specificity by hand. Worth knowing before you sprinkle :has() everywhere and wonder why one override stopped working.

A second selector in the same family handles the mobile drawer. I wanted the page to lock scroll when the menu is open, and I was toggling a body.menu-open class in JS. Now a hidden checkbox drives it and the body reacts to a state further down the tree is not possible (you cannot select up to
from a deep checkbox without a wrapper), so I scope it to the app shell instead:


.app:has(#menu-toggle:checked) .app__scroll {
  overflow: hidden;
}

Enter fullscreen mode Exit fullscreen mode

The drawer open and close animation, the overlay fade, and the scroll lock all run from one checkbox state. That is two selectors, zero JS, replacing what used to be a small state machine. The View Transitions work I did later leans on the same instinct of letting the platform hold state. Background on that is in View Transitions API patterns I use across my sites.

Empty states and quantity-aware layout, decided by content

This is the selector that made me a believer. A cart, a search result list, a dashboard widget: they all need to look different when empty versus full, and they often need to react to how many children exist. The classic approach counts items in JS and sets a data-count attribute or an is-empty class.

:has() reads the content directly. Empty state first:


.cart:has(.cart__item) .cart__empty { display: none; }
.cart:not(:has(.cart__item)) .cart__items { display: none; }
.cart:not(:has(.cart__item)) .cart__checkout { display: none; }

Enter fullscreen mode Exit fullscreen mode

The empty message shows only when there are no items, and the checkout button hides itself. No counting, no flag. When the last item is removed from the DOM, the layout flips on its own.

Quantity-aware layout uses :has() with quantity selectors. I wanted a results grid that switches to a single centered column when there is exactly one result, and a tighter grid past nine:


.results:has(.card:only-child) {
  grid-template-columns: minmax(0, 36rem);
  justify-content: center;
}
.results:has(.card:nth-child(10)) {
  gap: 8px;
}

Enter fullscreen mode Exit fullscreen mode

The second rule fires only once a tenth card exists, so dense lists get tighter spacing automatically. Pair it with :has() to detect a loading skeleton and dim the surrounding toolbar, and you have an entire responsive-to-content system with no resize observers and no item counters. This replaced about 50 lines across the cart and the lab search page.

The one caveat worth repeating: :has() reacts to the DOM as rendered, so if your framework virtualizes a long list and only paints 20 of 400 rows, the :nth-child math counts the painted rows, not the data behind them. Know your renderer. In practice this only bit me once, on a virtualized log viewer, and the fix was to drive the count selectors off a small wrapper attribute the renderer already set, then use :has() for the purely visual states inside each row. The rule of thumb I landed on: use :has() for "does this thing contain that thing" questions, and let your data layer answer "how many" when the list is virtualized.

Theme toggles, image cards, and selected table rows

Three smaller wins that each killed a chunk of script.

Theme toggle without JS. I used to listen for a toggle, write data-theme to `, and persist it. For a no-persistence toggle (a preview switch in a settings panel), a checkbox and:has()` are enough:

`css

.preview:has(#dark-toggle:checked) {
--bg: #1f1f21;
--text: #F5F5F7;
}

`

The whole preview pane re-themes from one checkbox. For the site-wide theme I still use a few lines of JS, only because I want the choice saved to storage and applied before first paint to avoid a flash. Visual switching, though, needs no script.

Card-with-image variants. A content card should lay out differently when it has a thumbnail versus when it is text only. Instead of adding a has-image class server-side, the card detects its own image:

`css

.card:has(.card_media) {
grid-template-columns: 8rem 1fr;
}
.card:has(.card
media img[src$=".svg"]) .card_media {
padding: 16px;
}

`

The layout adapts to whatever the CMS sends, and SVG logos even get extra padding so they do not sit edge to edge. No template branching.

Selected table rows. Bulk-select tables usually toggle a row-selected class on every checkbox change. With :has(), the row styles itself and the table header can show a bulk-action bar the moment any row is checked:

`css

tr:has(input[type="checkbox"]:checked) {
background: rgba(227, 252, 2, 0.08);
}
.table:has(tbody input:checked) .table__bulkbar {
display: flex;
}

`

That deleted the last row-selection listener I had. Three selectors, three fewer scripts. None of this needs a build step, a library, or a polyfill in 2026. :has() is Baseline (every modern engine shipped it by the end of 2023), so the only browsers that miss it are ones you have already dropped.

Bottom Line

Six selectors, four sites, roughly 240 lines of JavaScript gone. The pattern is always the same: find the place where JS is reading the DOM just to add a class, and let :has() read the DOM instead. Forms react to their own validity, nav follows aria-current, carts respond to their contents, and tables light up selected rows, all without listeners that desync on re-render. The UI got more robust because the state lives in one place, the markup, not in a script trying to mirror it.

Start with the empty-state selector. It is the lowest risk and the easiest to feel. Then go hunting for classList.toggle calls and ask whether the element could just look at its own children. Most of mine could. If you want the rest of how I keep RAXXO's frontend lean and on-brand, the work and the tools I lean on live at the RAXXO Studios page. Steal the selectors, ship them, delete some code.

Top comments (0)