EmpireUI
Get Pro
← Blog8 min read#:has()#css#selector

:has() Parent Selector in CSS: The One You've Been Waiting For

The CSS :has() selector finally lets you style parent elements based on their children — no JavaScript required. Here's how to actually use it in 2026.

CSS code on a dark screen showing parent selector patterns

What :has() Actually Does (and Why It Took So Long)

For about 25 years, CSS selectors were strictly top-down. You could style a child based on its parent's class, but you could never style a parent based on what its children were doing. Every time you needed that — and you needed it constantly — you'd reach for JavaScript, add a class to the DOM manually, and feel bad about it. That's finally over.

:has() is a relational pseudo-class that lets you select an element *if* it contains a matching descendant. form:has(input:invalid) highlights the entire form when a field fails validation. li:has(> ul) targets only list items that have a nested sub-list. div:has(img) selects any div wrapping an image. The direction of the relationship is inverted from everything else in CSS, which is exactly what makes it so useful.

Browser support landed progressively starting in 2023 and by late 2024 it had crossed the baseline threshold. As of mid-2026 you're looking at 96%+ global support across Chrome 105+, Safari 15.4+, Firefox 121+, and Edge 105+. The one concern is Firefox — it shipped :has() in version 121 but with a subtle performance caveat around dynamic re-evaluation inside shadow DOM that's mostly academic for normal UI work.

Honestly, the reason it took this long was performance. The CSS Working Group worried that arbitrary ancestor selection would force browsers to re-evaluate entire style trees on every DOM mutation. Modern browser engines solved it with scope-limiting strategies, which is why support suddenly appeared across all three engines within the same 18-month window.

The Syntax You Need to Know

The core form is dead simple: A:has(B) means "select A if it contains B." B can be any valid CSS selector — pseudo-classes, combinators, compound selectors, the works.

/* Any article that contains an h2 */
article:has(h2) {
  border-left: 3px solid currentColor;
}

/* Only cards that have a direct child img */
.card:has(> img) {
  padding-top: 0;
}

/* Form with at least one invalid input */
form:has(input:invalid) {
  outline: 2px solid #ef4444;
}

/* Parent li that contains a nested ul */
li:has(> ul) > a {
  font-weight: 700;
}

The > child combinator inside :has() is important — it lets you distinguish between a direct child and any descendant. Without it you're matching any depth. That distinction matters more than you'd think when you're dealing with complex component trees.

You can also chain :has() with other pseudo-classes and combine multiple conditions using a comma-separated list inside the parens. section:has(h2, h3) matches a section containing *either* heading level. section:has(h2):has(p) means it must contain *both*. That AND logic is what makes :has() feel genuinely expressive rather than a one-trick novelty.

Worth noting: you can also use :has() as part of a larger relational chain. nav:has(+ main) selects a nav that is immediately followed by a main element — that's combining :has() with the adjacent sibling combinator, and it opens up layout-level logic that used to require JavaScript.

Practical Patterns That Replace JavaScript

Let's talk about real cases. The most immediately useful one is form state. Before :has(), showing an error ring around the whole form required JavaScript listening to input events, then toggling a class. Now it's two lines of CSS. No event listener, no class management, no risk of stale state.

/* Highlight the whole form container on any invalid field */
.form-group:has(input:invalid:not(:placeholder-shown)) {
  background-color: #fef2f2;
  border-color: #fca5a5;
}

/* Show the error message that lives *after* the input */
.form-group:has(input:invalid:not(:placeholder-shown)) .error-msg {
  display: block;
}

The :not(:placeholder-shown) part is the trick for avoiding false errors on empty fields — you only want the validation styling after the user has actually typed something. That combo has been a CSS pattern since 2019 but it pairs especially cleanly with :has().

Another pattern: conditional layout switching based on content. If a card has an image, you want it laid out differently than a text-only card. Before :has() you'd either hardcode two component variants in JSX or use an IntersectionObserver hack. Now: ``css .card:has(img) { display: grid; grid-template-columns: 200px 1fr; gap: 16px; } .card:not(:has(img)) { padding: 24px; } `` This is the kind of content-driven layout adaptation that CSS was always *supposed* to be good at but couldn't do without JavaScript.

Look, there's also a genuinely fun use case for UI libraries and design systems: flagging components that are structurally wrong. If your <Button> is supposed to always wrap an icon but someone forgets, you can visually call it out in development: ``css /* Dev-mode sanity check */ .btn:not(:has(svg)):not(:has(span.icon)) { outline: 4px dashed hotpink; } `` Not something you'd ship to production, but incredibly useful during component authoring. If you're building a full design system with Empire UI, patterns like this slot in naturally alongside existing utility classes.

Styling Siblings Based on a Sibling's State

Here's the part that breaks people's brains the first time they see it. :has() isn't limited to parent-child relationships. You can use it on a *common ancestor* to affect sibling elements. That makes it a pseudo-sibling selector, which didn't exist in CSS before 2023.

/* When a checkbox inside .toggle-group is checked,
   style the label that sits NEXT to the input */
.toggle-group:has(input:checked) label {
  color: #6366f1;
  font-weight: 600;
}

/* Dark the whole row when its status badge shows 'error' */
tr:has(.badge--error) {
  background-color: #fef2f2;
}

The tr:has(.badge--error) one is probably the example that gets the most "oh wow" reactions from developers who've spent years fighting this exact problem in table UIs. You used to need a JS loop iterating rows, checking cell content, then toggling classes. One CSS rule handles it now.

In practice, the most powerful application is custom checkbox and toggle implementations. You can build a fully styled, accessible toggle switch in pure CSS — including the adjacent label state — without any JavaScript event handlers. The :has(input:checked) pattern is the missing piece that makes it work end-to-end.

Quick aside: if you're building interactive UI components with multiple states, this pairs beautifully with CSS custom properties. Set a --card-accent variable on the parent inside a :has() rule, and every child that references that variable updates automatically. It's a clean pattern for theming that keeps your CSS well-organized without JavaScript-driven class toggling. You can see similar token-driven patterns in Empire UI's glassmorphism components.

Performance: What You Actually Need to Watch

:has() is fast in practice but it's not free in theory. Every :has() rule adds a "scope" boundary that the browser's style engine has to track. When the DOM inside that scope changes, the browser re-evaluates the rule. For static content that's irrelevant. For highly dynamic lists — think 500 rows being filtered in real time — it can matter.

The rules for keeping it fast are similar to other expensive selectors. Avoid putting :has() on a selector with a very broad match like *:has(...) or body:has(...). Keep the left-hand selector as specific as possible. A rule like .data-table tr:has(.badge--error) is fine. A rule like *:has(input:focus) crawling the entire document tree on every keypress is going to cause you pain.

/* Avoid: too broad, re-evaluates the whole document */
body:has(input:focus) {
  background: #fafafa;
}

/* Better: scope it to the relevant component */
.form-section:has(input:focus) {
  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}

One more thing — :has() inside @media queries and @container queries works fine. It evaluates after the media condition is met, so you're not adding :has() overhead for rules that never even apply. That's worth knowing if you're worried about mobile performance.

Using :has() in Component-Driven Design Systems

Where :has() really shines is inside component scopes. If you're using CSS Modules, Tailwind's @layer system, or any kind of scoped stylesheet, you can use :has() to encode component logic that previously lived in JavaScript.

Take a modal dialog. You want the body scroll locked when a modal is open. The classic approach is document.body.classList.add('modal-open') on open and remove on close. With :has() you can skip all of that: ``css body:has(dialog[open]) { overflow: hidden; } ` That's it. The browser handles it. The dialog[open] attribute is set by the native showModal() API, and :has()` picks it up automatically. No JavaScript class toggling required.

Another real-world pattern: responsive image grids that adapt to count. In a photo gallery where the number of images varies, you'd normally reach for JavaScript to count children and set a class. With :has() you can approximate it: ``css .gallery:has(> :nth-child(3):last-child) { grid-template-columns: repeat(3, 1fr); } .gallery:has(> :nth-child(4):last-child) { grid-template-columns: repeat(2, 1fr); } ` Combining :nth-child(N) with :last-child inside :has()` is a quantity query — you're asking "does this element have exactly N children?" It's a technique that's been theorized since 2011 and is finally usable without any polyfill.

If you're building components that need complex visual states, this plays really well with existing style systems. The gradient generator and box shadow generator on Empire UI both generate CSS you can drop into rules like these directly. Combine generated values with :has() conditions and you've got genuinely expressive, zero-JS component styling.

What :has() Still Can't Do

No selector is magic. :has() has real limitations worth knowing before you over-index on it. The most important: you can't select elements inside a shadow DOM from outside it. If a web component's internal structure is encapsulated, :has() on the host element can't peer inside the shadow root. That's by design — shadow DOM encapsulation is a feature — but it means custom element library users need to expose part attributes or CSS custom properties for external theming.

The second limitation: :has() doesn't work in CSS content strings or attr() expressions. It's a selector, not a value. You can't use it to generate content dynamically. That sounds obvious but it trips people up when they're trying to do something like render a counter badge using only CSS — :has() can trigger the display, but you'd still need counter() or a data attribute for the actual number.

That said, :has() combined with @counter-style and attr() can get surprisingly far. If your markup sets data-count attributes, you can drive a lot of display logic purely in CSS. Whether that's a good idea architecturally is a separate question — CSS is getting powerful enough that the line between "clever" and "unmaintainable" is something you need to draw yourself.

In practice, :has() is most valuable for UI state that's already reflected in the DOM via native attributes ([open], [disabled], :invalid, :checked, :empty) rather than custom class juggling. When the DOM truthfully represents state, :has() is a first-class tool. When state lives in a JavaScript variable and you're manually syncing it to the DOM, you're still writing the same two lines of JS — you're just maybe writing fewer after that.

FAQ

Is :has() safe to use in production in 2026?

Yes. Global browser support is above 96% as of mid-2026, covering all modern Chrome, Safari, Firefox, and Edge versions. Add a reasonable fallback for the small percentage of older Firefox users if your analytics show them.

Can I use :has() with Tailwind CSS?

Tailwind v3.4+ ships a has-* variant — for example, has-[input:invalid]:ring-red-500 applies the class when a descendant matches. It maps directly to the native :has() selector under the hood.

Does :has() work with CSS Modules or scoped styles?

Yes. :has() is just a CSS selector and works anywhere you write CSS, including CSS Modules, Styled Components, and Tailwind's @layer. Scoping doesn't affect how it evaluates the DOM.

Will :has() hurt performance if I use it a lot?

Only if you put it on very broad selectors like * or body with dynamic content inside the scope. Keep the left-hand selector specific and scoped to a component, and you won't notice any difference.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Container Query Animation: @container + @keyframes PatternsCSS Hover Effects Gallery: 10 Patterns Beyond color and opacityTailwind :has() Selector: Parent Styling Without JavaScript10 Tailwind Component Patterns Every Developer Should Know