:has() Selector: Recipes Beyond the Basics
AI generated
{ }
@
CSS · Selectors · Accessibility
:has() Selector: Recipes Beyond the Basics
Forms, empty states and theme logic without JavaScript

Anyone who only knows the has selector as a parent selecting tool is using a fraction of its potential. This collection shows concrete recipes for form validation, empty states, quantity queries and theme switching that previously required JavaScript and are now pure CSS.

17 min read has selector · quantity queries · empty states Chrome 105+ · Firefox 121+ · Safari 15.4+

1. Why the has selector is more than a parent selector

The has selector is usually introduced as the long awaited parent selector: article:has(img) selects any article that contains an image. That is accurate, but it sells the feature short. In truth, the has selector is a relational pseudoclass that checks whether any selector inside its argument matches a descendant, a sibling, or a combination of both. That argument can be any selector at all, including combinators, attribute selectors and further pseudoclasses such as :not() or :checked.

This flexibility makes the has selector one of the most powerful tools CSS has gained in recent years. Much of what previously required a JavaScript class on the parent element, such as form.has-error or body.dark-mode, can now be expressed directly in the stylesheet. The state of a descendant becomes the condition for styling an ancestor, without any JavaScript touching the DOM. The following sections show concrete recipes that go far beyond plain parent selection and use the has selector as a full state management tool.

2. Syntax recap, then straight into recipes

The basic syntax of the has selector is quickly explained: selector:has(argument) matches when at least one element exists inside selector that matches the argument. The argument is read relative to the element carrying :has(), not relative to the whole document. li:has(> a) uses the child combinator to only check direct children, while li:has(a) also catches deeply nested links. This difference often decides in practice whether a recipe works precisely or matches unexpectedly too many elements.

Important for every recipe that follows: the has selector can be chained multiple times and combined with other selectors. form:has(input:invalid):not(:has(input:focus)) is a valid, if complex, expression. The more nested the expression, the more readability matters, so it pays to split complex has selector expressions into custom properties or well commented rule blocks instead of cramming everything into one long line.


/* Basic has() recap: parent gets a state from its children */
article:has(img) {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: 1.5rem;
}

/* Direct child combinator narrows the match precisely */
li:has(> a.external) {
  border-left: 3px solid #7c3aed;
}

/* Combined with :not() for exclusion logic */
form:has(input:invalid):not(:has(input:focus)) {
  outline: 2px solid #dc2626;
  outline-offset: 4px;
}

3. Recipe: form validation without JavaScript

Form validation is the flagship example that proves the value of the has selector. Without :has(), JavaScript had to check a field's error state and set a class on the surrounding container so a label could turn red or a hint could appear. With the has selector, a single selector is enough: .field:has(input:invalid) label { color: #dc2626; } automatically colors the label as soon as the input's native validation state is invalid, with no submit event and no JavaScript listener at all.

The recipe becomes even more useful once :placeholder-shown is added, showing errors only after the user has typed something rather than immediately on page load. .field:has(input:invalid:not(:placeholder-shown)) shows error styling only when the user has already entered something and the result is invalid. This combination of the has selector and native pseudoclasses replaces much of the validation logic that previously required JavaScript, while staying fully declarative inside the stylesheet.


/* Show error styling only after the user has typed something */
.field:has(input:invalid:not(:placeholder-shown)) label {
  color: #dc2626;
  font-weight: 600;
}

.field:has(input:invalid:not(:placeholder-shown))::after {
  content: "Please enter a valid value";
  display: block;
  font-size: 0.8rem;
  color: #dc2626;
}

/* Success state without any JavaScript validation logic */
.field:has(input:valid:not(:placeholder-shown)) input {
  border-color: #16a34a;
}

/* Submit button reacts to overall form validity */
form:has(input:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

4. Recipe: reliably detecting empty states

A second highly practical recipe for the has selector is detecting empty containers. Cart widgets, search result lists and notification centers often need to look different when they hold no entries, for example with a placeholder text and different padding. Previously, this state was mostly decided by a backend template condition or a JavaScript count of child elements. With the has selector, the empty state can be queried directly in CSS: ul.cart-items:not(:has(li)) only matches when the list contains not a single row.

The decisive trick is combining :not() with the has selector: instead of checking whether something is present, it checks whether nothing is present, and exactly that negated state gets its own styling. This works regardless of whether the list is rendered server side or filled later via fetch, as long as the real DOM state decides in the end. For more complex containers with several possible child types, the recipe extends easily, for example .results:not(:has(.result-item, .result-card)), covering several possible entry types at once.


/* Empty cart: no <li> children present at all */
ul.cart-items:not(:has(li)) {
  display: grid;
  place-items: center;
  padding: 3rem 1rem;
}

ul.cart-items:not(:has(li))::before {
  content: "Your cart is empty";
  color: #6b7280;
  font-size: 0.95rem;
}

/* Works for multiple possible item types at once */
.search-results:not(:has(.result-item, .result-card)) {
  min-height: 12rem;
  background: #f8fafc;
  border-radius: 0.75rem;
}

5. Recipe: quantity queries by child count

Quantity queries describe a technique where styling depends on the number of present child elements, for example a different grid layout with three items than with seven. Before the has selector, this only worked with cumbersome, hard to maintain combinations of :nth-child and :nth-last-child. With the has selector and the general sibling combinator, counting becomes far more readable: .grid:has(> :nth-child(4)) matches as soon as at least four direct children exist, because a fourth child must be present for the selector to match at all.

This pattern can be extended into a complete quantity query by combining a lower and upper bound. .grid:has(> :nth-child(4)):not(:has(> :nth-child(7))) describes exactly the range between four and six child elements. The has selector makes this condition declarative and expressible without any JavaScript count, which is especially useful for responsive grid layouts where the column count should depend on the number of cards, not only on viewport width.


/* Fewer than 4 items: single column, larger cards */
.grid:not(:has(> :nth-child(4))) {
  grid-template-columns: 1fr;
}

/* Between 4 and 6 items: two columns */
.grid:has(> :nth-child(4)):not(:has(> :nth-child(7))) {
  grid-template-columns: repeat(2, 1fr);
}

/* 7 or more items: dense three-column grid */
.grid:has(> :nth-child(7)) {
  grid-template-columns: repeat(3, 1fr);
  gap: 0.75rem;
}

6. Recipe: sibling context with combinators

The has selector shows its full strength combined with the general sibling combinator ~. It lets an element be styled depending on a sibling appearing later in the markup, something plain CSS combinators could not do before, since they traditionally only reach forward. A practical example: a checkbox further up in a form should visually highlight a field further down once it is checked. With input[type="checkbox"]:checked ~ .conditional-field, this was already solvable, but only if the target element was a direct sibling.

The has selector extends this to arbitrarily nested structures: .form-section:has(~ .form-section input:checked) reacts to a checkbox anywhere in a later section, regardless of nesting depth. This backward reference was the real reason developers demanded a parent selector for years, and the has selector solves exactly that problem more elegantly than the original request ever envisioned.

7. Recipe: theme switching without body classes

Many projects switch between light and dark design by having JavaScript set a class such as dark-mode on the body element. With the has selector, the same effect can be achieved without JavaScript managing any class at all: a hidden checkbox or a native details element holds the state, and body:has(#theme-toggle:checked) reacts directly to it. The advantage is that the state lives entirely in the HTML and is correct immediately on page load, without waiting on JavaScript hydration.

This recipe is especially suited for prototypes and for projects that deliberately keep JavaScript minimal. The has selector does not replace a full theme management system with system preference detection here, but for simple switches between two or three variants the solution is robust, performant and independent of script loading times. Combined with prefers-color-scheme as a default, a largely CSS native theme system can be built.


/* State lives in the checkbox, no JavaScript class management needed */
body:has(#theme-toggle:checked) {
  --bg: #0f172a;
  --fg: #f1f5f9;
}

body:not(:has(#theme-toggle:checked)) {
  --bg: #ffffff;
  --fg: #0f172a;
}

body {
  background: var(--bg);
  color: var(--fg);
  transition: background 0.2s ease, color 0.2s ease;
}

8. Performance and browser support in practice

A legitimate concern with any new selector is rendering performance. The has selector theoretically has to check descendants to make a statement about the ancestor, which sounds more expensive than a simple class comparison. In practice, browser engines have built internal optimizations for this, such as invalidation sets that only re-evaluate on relevant DOM changes instead of scanning the whole subtree on every repaint. For the vast majority of real world use cases, such as forms with a few dozen fields or card grids with a few dozen items, the difference is not measurable.

It becomes critical only in very large, deeply nested trees with frequent DOM mutations, such as virtualized lists with thousands of rows that the has selector would have to re-evaluate in every section. It is worth profiling performance in the browser before deploying the selector in such cases. On browser support: all current evergreen browsers have supported the has selector since 2023, older browsers need a fallback via feature detection with @supports selector(:has(a)) so the layout degrades gracefully instead of breaking.

9. has selector versus JavaScript alternatives

Not every recipe shown here replaces JavaScript entirely, but in many cases the CSS solution is more robust because it works independently of script loading times. The following table contrasts the typical approaches and shows where the has selector is the clearly better choice and where JavaScript remains necessary.

Use case JavaScript approach has selector recipe Recommendation
Form validation Event listener sets error class :has(input:invalid) CSS is sufficient
Empty states Count children, set class :not(:has(li)) CSS is sufficient
Quantity queries Evaluate array length :has(> :nth-child(n)) CSS is sufficient
Server side form logic Backend validation, session Not expressible JavaScript/backend required
Persistent theme state localStorage, system preference Simple switching only Combine both approaches

The has selector therefore does not replace JavaScript generally, only precisely where the state is derivable purely from the DOM itself. As soon as persistence beyond a single page view or server side logic is needed, JavaScript or the backend stays responsible, while the has selector takes over pure presentation logic.

Mironsoft

Modern CSS, Hyvä themes and maintainable frontend architecture

CSS selectors that replace JavaScript instead of duplicating it?

We audit existing frontend logic and replace unnecessary JavaScript with robust, modern CSS selectors like the has selector, for faster load times and more maintainable code.

CSS Audit

Checking existing stylesheets for unnecessary JavaScript and outdated patterns

Form Refactoring

Migrating validation logic to native pseudoclasses and the has selector

Hyvä Integration

Cleanly integrating modern selectors into Tailwind and Alpine components

10. Summary

The has selector is far more than a parent selector: it is a relational state tool that translates the DOM state of descendants and siblings into styling conditions. Form validation, empty states, quantity queries and simple theme switching can all be solved declaratively in the stylesheet, without JavaScript manipulating the DOM or managing classes. The combination with :not(), the child combinator and the general sibling combinator opens up patterns that were simply not expressible before.

It remains important to use the has selector deliberately: wherever the state is fully derivable from the current DOM, it is the more robust and faster solution. As soon as persistence beyond the page or server side logic is needed, a combination with JavaScript stays sensible. Anyone who knows these recipes can immediately replace several lines of JavaScript with a few lines of CSS in most projects.

has Selector Recipes: The Key Points at a Glance

Forms

:has(input:invalid:not(:placeholder-shown)) shows errors only after input, entirely without JavaScript validation.

Empty States

:not(:has(li)) reliably detects empty containers regardless of how they get filled.

Quantity Queries

:has(> :nth-child(n)) turns child count into a styling condition, more readable than pure nth-child chains.

Performance

Uncritical for typical UI sizes, profile beforehand for very large trees and plan a fallback via @supports.

11. FAQ: has Selector Recipes

1What makes the has selector different?
It checks relationally whether a matching element exists inside its argument, descendant or sibling, and styles the ancestor based on that.
2Does it replace JavaScript entirely?
Only where the state is fully derivable from the current DOM. Persistence and backend logic remain a JavaScript or server task.
3How do I detect empty containers?
With ul:not(:has(li)) for exactly the state without children, extendable via comma for multiple child types.
4What are quantity queries?
Layout adjustments by child count, with .grid:has(> :nth-child(4)) far more readable than pure nth-child chains.
5Does it work with the sibling combinator?
Yes, it lets an element be styled depending on a later sibling, a genuine backward reference.
6Is it performant enough?
For typical UI sizes yes, for very large, frequently mutating trees, profile in the browser first.
7What does a safe fallback look like?
With @supports selector(:has(a)) check support and provide a simpler fallback style.
8Can it be nested multiple times?
Yes, but as complexity grows, split into individual, commented rules for readability.
9Is it suitable for theme switching?
For simple switchers yes, for persistent, system wide management combining with JavaScript still makes sense.
10Difference between :has(a) and :has(> a)?
:has(a) also matches deeply nested links, :has(> a) only direct children. The child combinator makes the selector more precise.