Web Development

The CSS :has() Selector in Practice: Real Layout Problems It Solves

Three real layout problems that used to need JavaScript, solved with :has() alone — a form validation state, a sibling-aware card layout, and the browser-support fallback that still matters.

By Aissam Ait Ahmed Web Development 0 comments

CSS never had a way to style a parent based on what's inside it — a "parent selector" was on developers' wishlist for over a decade, worked around with JavaScript every time it came up. :has() closes that gap directly in CSS, and it's easiest to understand through real layout problems it actually solves rather than through the selector syntax alone.

Problem 1: styling a form field's container based on validation state

A common pattern: a form field's wrapping <div> needs a red border when the input inside it is invalid, but CSS's :invalid pseudo-class only applies to the input itself, not to its parent container that actually needs the visual treatment. Before :has(), this required either JavaScript toggling a class on the parent, or restructuring the DOM so the input came before the elements needing the styling, relying on the general sibling combinator instead.

.field-wrapper:has(input:invalid) {
    border: 1px solid #dc2626;
    background: #fef2f2;
}

.field-wrapper:has(input:invalid) .field-label {
    color: #dc2626;
}
<div class="field-wrapper">
    <label class="field-label" for="email">Email</label>
    <input type="email" id="email" required>
</div>

The wrapper and the label both react to the input's validity state, purely in CSS, regardless of DOM order between them — :has() checks descendants generally, not just following siblings, which is exactly the gap the older ~ general sibling combinator couldn't close on its own.

Problem 2: a card grid where a card containing an image lays out differently

A card grid needed cards containing a featured image to span two columns, while text-only cards stayed at a single column width — a genuinely common editorial layout pattern. Previously, this meant either a server-side or JavaScript check adding a modifier class (.card--featured) to the card wrapper based on its content, duplicating logic that's already fully expressed in the markup itself.

.card:has(img) {
    grid-column: span 2;
}

.card:has(img) .card-title {
    font-size: 1.5rem;
}

This removes an entire category of "does this card have an image, and if so add a class" logic that would otherwise live in a template or a small script — the styling now derives directly and automatically from what's actually present in the markup, with no separate state to keep in sync between the content and a class name representing that content.

Problem 3: highlighting a table row that contains a specific status cell

tr:has(td.status-overdue) {
    background: #fef2f2;
}

tr:has(td.status-overdue) td:first-child {
    font-weight: 600;
}

A table row highlighting itself based on one specific cell's content is another case that previously needed either a class on the <tr> itself (duplicating information already present on the cell) or JavaScript scanning cells after render and adding that class dynamically. With :has(), the row's styling derives directly from its actual content, and a status change that updates the cell's class automatically and correctly updates the row's appearance with it, with nothing extra to keep synchronized.

A genuinely useful trick: :has() as a "does this exist anywhere on the page" check

/* Hide an empty-state message only when there's at least one item */
.item-list:has(.item) + .empty-state {
    display: none;
}

Combining :has() with the adjacent-sibling combinator lets a completely separate element react to whether another element contains anything — hiding an "empty state" message specifically when the list next to it actually has content, all in CSS, with no JavaScript checking element counts and toggling visibility manually.

Where :has() genuinely can't replace JavaScript

:has() reacts to what's structurally present in the DOM and to states CSS pseudo-classes already track (:invalid, :checked, :hover) — it doesn't run arbitrary logic, react to values inside form inputs beyond their built-in validity state, or respond to anything computed at runtime that isn't expressed as a DOM structure or a CSS-tracked state. A card that needs to change layout based on a numeric value inside its text content — "highlight this card if the price is over $100" — is not something :has() can evaluate on its own; that genuinely still needs JavaScript to read the value and apply a class or attribute CSS can then key off of.

Browser support and a practical fallback strategy

:has() reached baseline support across all major browsers in late 2023 and has continued to solidify since, which by 2026 covers the overwhelming majority of real traffic for most sites — but "overwhelming majority" isn't "all," and a layout-critical use of :has() still deserves a fallback for the remaining sliver of older browsers rather than assuming universal support without checking your own actual traffic data first.

/* Baseline: works everywhere, slightly less precise */
.field-wrapper.has-error {
    border: 1px solid #dc2626;
}

/* Enhancement: browsers supporting :has() get the same
   result without needing JavaScript to add the class at all */
@supports selector(:has(*)) {
    .field-wrapper:has(input:invalid) {
        border: 1px solid #dc2626;
    }
}

@supports selector(:has(*)) specifically detects :has() support and scopes the CSS-only version to browsers that have it, while a JavaScript-driven class toggle continues covering the rest — this isn't an all-or-nothing choice, and for anything genuinely layout-critical (not just a visual nicety), keeping both paths available for a transition period is a reasonable, low-risk approach until support is unambiguous for your specific audience.

What actually changed in day-to-day CSS work

  • A meaningful share of "add a class via JavaScript based on DOM content" logic is now expressible directly in CSS, which means one less place for markup and styling logic to drift out of sync with each other over time.
  • The card-grid and table-row examples above both replaced a small amount of JavaScript that existed purely to keep a class name synchronized with content that was already fully present in the DOM — removing code whose only job was mirroring information that already existed elsewhere.
  • It's not a JavaScript replacement in general — anything requiring actual computation on values, not just structural presence or built-in pseudo-class state, still needs a script somewhere in the pipeline.

If you're working through other modern CSS capabilities that changed how much JavaScript a given layout actually needs, CSS container queries in practice covers a similarly JavaScript-replacing feature for a different problem — component layout based on its container's size rather than content presence — and if the form-validation example above is part of a larger form redesign, five accessibility fixes teams miss most often is worth checking alongside it, since a visually correct error state still needs to be announced to assistive technology, which :has() alone doesn't handle.

Performance: the concern that turned out to be smaller than expected

A relative selector that can match based on arbitrary descendants sounds like it could be expensive to evaluate on a large page, and this was a real concern before adopting :has() broadly — CSS selector matching happens on every style recalculation, and a selector that has to check a subtree rather than a single element's own properties is doing genuinely more work per match. In practice, on the pages we tested — including the table example above, with several hundred rows — the measured style recalculation time in DevTools' Performance panel showed no meaningful difference between the :has() version and the previous JavaScript-class-toggling version. Browser vendors specifically optimized :has() implementations around exactly this concern before shipping it broadly, precomputing which elements are watched for changes rather than re-scanning the whole subtree on every recalculation.

This isn't a blanket assurance that :has() is free in every case — a selector like body:has(.deeply .nested .thing) matched against a genuinely enormous DOM is a different performance question than the bounded examples in this post, and it's worth actually measuring your own specific case in DevTools rather than assuming either "it's always fine" or "it's always risky" without checking.

A fourth real case: disabling a submit button until a required field group is valid

form:has(.required-field:invalid) button[type="submit"] {
    opacity: 0.5;
    pointer-events: none;
}

A submit button that disables itself, in pure CSS, based on whether any required field within the same form is currently invalid — no JavaScript listening for input events across every field, no manually toggling a disabled attribute in response to each keystroke. The button's state is a direct, always-current reflection of the form's actual validity, expressed declaratively rather than maintained procedurally, which also means it can never drift out of sync the way a JavaScript-maintained disabled state occasionally can if an edge case in the event listeners gets missed.

Migrating away from an existing JavaScript-based version

For a codebase with existing JavaScript handling cases like these, the migration is worth doing incrementally rather than all at once — replacing one class-toggling script with its :has() equivalent, verifying visually and with the @supports fallback in place, then moving to the next one, rather than a single large refactor touching every instance simultaneously. The card-grid and table-row cases above were both migrated this way over a couple of weeks, each verified independently, which made it straightforward to isolate and revert a single change if something regressed, rather than debugging a large batched change where an unrelated visual regression could have come from any one of several simultaneous replacements.

What to actually check before removing the JavaScript version

  • Confirm the CSS-only version renders identically across the browsers your real traffic actually uses, not just the one you're developing in — check your analytics for the true browser mix before assuming near-universal support applies to your specific audience.
  • Keep the JavaScript fallback behind @supports for a transition period rather than deleting it the same day the CSS version ships, especially for anything layout-critical rather than purely cosmetic.
  • Only remove the fallback once you've confirmed, from real traffic data, that the remaining unsupported-browser share is small enough to accept — a decision that depends on your actual audience, not a general industry-wide support percentage that may not reflect who's actually visiting your specific site.
Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Web Development Free Resources Explore Tools