Understanding :has() and Its Place Among Functional Pseudo-Classes
The relational selector :has() brings a capability to CSS that previously required JavaScript: the ability to look ahead and style a parent or ancestor element based on its children or later siblings. This opens up styling patterns that were either cumbersome or impossible with traditional CSS selectors.
:has() is part of a family of functional pseudo-classes that includes :is(), :where(), and :not(). These share some traits but differ in key ways:
:is()takes the highest specificity from its selector list.:where()always has zero specificity, making it easy to override.:not()was enhanced in Selectors Level 4 to accept selector lists rather than a single selector, with specificity behavior matching:is().
Both :is() and :where() are also forgiving selectors, meaning they process valid parts of a selector list even if other parts are unrecognized. Without this behavior, the browser would discard the entire rule.
One important detail for building advanced selectors: the universal selector * used within :is(), :where(), and :not() refers to the selector target itself. This allows checking preceding siblings or ancestors relative to the target. For example, p:is(h2 + *) matches paragraphs that immediately follow an h2.
Note: Browser support for :has() is growing. It ships in Safari 15.4 and Chrome/Edge 105, and is behind a flag in Firefox as of version 103. Until full support lands, see Bramus Van Damme's tip for progressive enhancement.
Building an :only-of-selector Polyfill
The standard :only-of-type pseudo-class has a limitation: it only considers element types, not classes or other selectors. For instance, .highlight:only-of-type fails to match when multiple elements share the class because the class doesn't reduce the scope of sibling comparison.
<p>Not highlighted</p>
<p class="highlight">.highlight</p>
<p>Not highlighted</p>
Combining :has() with :not() creates an effective :only-of-selector that works with any valid selector. The goal is to match an element only when no matching siblings exist before or after it.
First, use :has() with the general sibling combinator ~ to test for any following siblings that match:
.highlight:not(:has(~ .highlight)
This matches highlights that have no subsequent sibling highlights. Next, add a condition using :not() to exclude elements that have a matching sibling preceding them:
.highlight:not(:has(~ .highlight)):not(.highlight ~ *)
The second condition acts as an AND clause, resulting in a polyfilled :only-of-selector:
See the Pen [:only-of-selector using :has() [forked]](https://codepen.io/smashingmag/pen/qByprrp) by Stephanie Eckles.
Selecting Previous Siblings
While checking preceding siblings traditionally requires workarounds, :has() makes it possible to select and style prior siblings based on what follows them.
Consider a list where hovering an item scales it up, with the items immediately before and after also scaling up slightly. Other items scale down and dim in opacity.
To target the item before the hovered one, use a selector that reads: "select the list item whose adjacent sibling is being hovered."
li:has(+ li:hover)
Pair this with an adjacent sibling selector to cover the item after the hovered one:
/* Select list item before the hovered one */
li:has(+ li:hover),
/* Select list item after the hovered one */
li:hover + li {
/* ...modify scale and opacity */
}
A more complex selector combines :has() and :not() to exclude the hovered item and its two neighbors from a group that receives reduced styles:
/* When a list item is being hovered,
select list items not hovered, or before/after hover */
ul:has(> :hover) li:not(:hover, :has(+ :hover), li:hover + *) {
/* ...modify scale and opacity */
}
See the Pen [Previous/Next Sibling Animation with :has() [forked]](https://codepen.io/smashingmag/pen/rNrpymj) by Stephanie Eckles.
This demonstrates not just previous-sibling selection but also state-based :has() usage. Similar explorations of this pattern appear in work by Chris Coyier, pourya, and Jim Nielsen.
Selecting Elements Within Ranges
When content is delimited by markers like h2 or hr elements, :has() can select specific parts of that range: the first element, the last element, or all siblings in between. These approaches rely heavily on the ~ general sibling combinator.
<article>
<h2>Lorem, ipsum.</h2>
<!-- h2 range starts -->
<p>Lorem ipsum, dolor sit amet consectetur adipisicing elit.</p>
<p>Nobis iusto voluptates reiciendis molestias, illo inventore ipsum?</p>
<!-- h2 range ends -->
<h2>Lorem, ipsum dolor.</h2>
<p>Lorem ipsum dolor sit amet.</p>
<hr>
<!-- hr range starts -->
<p>Lorem ipsum dolor sit.</p>
<p>Dolor animi nisi ut?</p>
<p>Sunt consectetur esse quia.</p>
<!-- hr range ends -->
<hr>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
</article>
First Element in Range
This selector matches the element directly after an h2, provided another h2 appears later as a sibling:
article h2 + :has(~ h2)
Last Element in Range
To match the final element before a closing marker, use a selector that reads "an element preceding an h2 that itself follows an h2":
article h2 ~ :has(+ h2)
All Siblings Within a Range
A broader selector targets every sibling between two markers, but it's limited to a single range per parent. Using ~ without a hard stop can cause "leap-frogging" over intervening elements, extending the effective range. This selector works when the parent contains only one possible range:
article hr ~ :has(~ hr)
For multi-range scenarios within one parent, the approach requires adjustment, which will be addressed next.
See the Pen [Select within an element range with :has() (limited) [forked]](https://codepen.io/smashingmag/pen/KKBZWqd) by Stephanie Eckles.
Working with a Single Full Range
Using a data attribute like data-range to mark range boundaries makes the selectors more explicit. This technique suits custom controls that visualize multi-select ranges, where the start and end are indicated but no values are assigned.
<ul>
<li>Lorem</li>
<li data-range>Veritatis</li>
<li>Eos</li>
<li>Debitis</li>
<li>Autem</li>
<li data-range>Atque</li>
<li>Eius</li>
<li>Lorem</li>
<li>Nostrum</li>
</ul>
Selecting both endpoints at once is straightforward with the attribute selector [data-range].
Reusing the earlier range selector, all siblings between the endpoints can be targeted:
[data-range] ~ :has(~ [data-range])
For the starting element, this selector reads: "select the [data-range] item that has another [data-range] sibling later in the list":
[data-range]:has(~ [data-range])
For the ending element, the selector reads: "select a [data-range] item that follows somewhere after a previous [data-range] item":
[data-range] ~ [data-range]
The full range visualization, including first and last element styling, is demonstrated in the accompanying CodePen:
See the Pen [Single range element selectors with :has() [forked]](https://codepen.io/smashingmag/pen/RwBxpgq) by Stephanie Eckles.
Selecting Groups With Start And End Markers
When you need to select a range that contains more than two elements inside a single parent, the challenge becomes finding clear boundaries. The earlier approach breaks down once an h2 or hr separates items into multiple potential ranges. The solution is to provide explicit start and end hooks in the markup, using data attributes with the literal values "start" and "end."
<ul>
<li>Lorem</li>
<li>Veritatis</li>
<li>Eos</li>
<li>Debitis</li>
<li>Autem</li>
<li>Atque</li>
<li>Eius</li>
<li>Lorem</li>
<li>Nostrum</li>
</ul>
With those attribute values in place, the selectors for the start and end indicators remain straightforward:
/* Start and end elements of range */
[data-range]
/* Starting element of range */
[data-range="start"]
/* Ending element of range */
[data-range="end"]
From there, style the first and last items within each group. The update from the simpler version is the addition of the exclusion condition :not([data-range]) so that the start/end markers themselves do not receive the same styles.
/* First element inside of range */
[data-range="start"] + :has(~ [data-range="end"]):not([data-range])
/* Last element inside of range */
[data-range="start"] ~ :has(+ [data-range="end"]):not([data-range])
The "within range" selector begins much like the earlier one, again adding the guard so that elements carrying the [data-range] attribute are omitted.
[data-range="start"] ~ :has(~ [data-range="end"]):not([data-range])
However, the general sibling combinator has a "leap-frog" behavior: it will happily style elements outside your intended group boundary. Without further restriction, the rule bleeds past the range.
To stop that, add a complex AND condition via :not(), excluding items that appear after a [data-range="end"] and before a later [data-range="start"]. In plain language, that portion reads as: do not select items that follow [data-range="end"] and also have a sibling [data-range="start"] later on.
/* Note: this needs appended on the previous selector, not used alone */
:not([data-range="end"] ~ :has(~ [data-range="start"]))
The resulting selector is long, but it unlocks a pattern that previously required JavaScript. Before :has() there was no way for CSS to "look ahead" or "look behind" to establish these boundaries.
/* Select all between a range */
[data-range="start"] ~ :has(~ [data-range="end"]):not([data-range]):not([data-range="end"] ~ :has(~ [data-range="start"]))
Just like any other selector,:has()can also be used insidequerySelector()and other JavaScript selection APIs. The ability to target previous siblings, ancestors, and more makes your JS selectors both shorter and more expressive.
See the Pen [Multi-range element selectors with :has() [forked]](https://codepen.io/smashingmag/pen/VwBypzB) by Stephanie Eckles.
State-Driven Range Selection
Combining :has() with stateful inputs opens the door to components like a star rating. The base element is a radio input, giving access to the :checked state.
<div class="star-rating">
<fieldset>
<legend>Rate this demo</legend>
<div class="stars">
<label class="star">
<input type="radio" name="rating" value="1">
<span>1</span>
</label>
<!-- ...4 more stars -->
</div>
</fieldset>
</div>
The interaction works like this: hovering over outlined stars fills them from left to right up to the hovered star. Once a star is selected, it scales up and remains filled. Hovering beyond the selected star fills the range up to the hover. Hovering before the selected star fills up to the hover, and the stars between the hover and the earlier checked star receive a lighter fill.
The selectors break into defined pieces. The first rule applies to all states where a star or range should be filled either up to the :checked star or up to the hovered star. It updates custom properties that drive the star shape, built from the ::before and ::after pseudo-elements on label.star.
Altogether, the rule selects the range between the first star and either the hovered or checked star.
.star:hover,
/* Previous siblings of hovered star */
.star:has(~ .star:hover),
/* Star has a checked radio */
.star:has(:checked),
/* Previous siblings of a checked star */
.star:has(~ .star :checked) {
--star-rating-bg: dodgerblue;
}
The following rule handles the lighter fill: it targets stars in the interval between the hovered star and a later checked star, as well as checked stars that come after the hover point.
/* Siblings between a hovered star and a checked star */
.star:hover ~ .star:has(~ .star :checked),
/* Checked star following a hovered star */
.star:hover ~ .star:has(:checked) {
--star-rating-bg: lightblue;
}
Beyond those two rules, no additional state selectors are needed for the core behavior. The accompanying demo rounds out the component with CSS grid, custom properties, and clip-path. For accessibility, color is not the dominant indicator—the checked star also scales up. The design supports high contrast themes by pulling fill colors from the system palette, and transitions shorten automatically when the user prefers reduced motion.
See the Pen [Star Rating Component with :has() [forked]](https://codepen.io/smashingmag/pen/ExpoWwv) by Stephanie Eckles.
Using State For Visual Group Boundaries
Stateful elements don't just drive dynamic styles; they can also mark visual boundaries. Checkbox groups are a nice example: a checked item gets its own border and green background, and thanks to :has(), the styling can extend so the whole group of checked items looks like one contained box. The first or only item gets rounded top corners; the last or only item gets rounded bottom corners and a shadow.
To keep the logic clean, write separate rules for the top, middle, and bottom appearance. A single checked item should receive all three treatments.
The markup wraps each checkbox input inside a label, so all selectors start with label:has(:checked) to detect labels that contain a checked input.
For the first or singleton item, add a condition that it is not preceded by a sibling label with a checked input. That instance receives the top styling.
/* First checked item in a range
OR top of a single checked item */
label:has(:checked):not(label:has(:checked) + label)
For the last or singleton item, invert that logic: it must not have a following sibling label containing a checked input.
/* Last checked item in a range
OR bottom of a single checked item */
label:has(:checked):not(label:has(+ label :checked))
The middle styling can come from a single rule that covers the full group with background and side borders. Although a simple label:has(:checked) would suffice, you can exercise the range-selector techniques by writing the expanded form. The first part catches all checked labels that have a later sibling also containing a checked input—meaning every item in the group except the last. That last item is handled by reapplying the "bottom" rule just created.
/* Range of checked items */
label:has(:checked):has(~ label :checked),
label:has(:checked):not(label:has(+ label :checked))
This approach weaves together accent-color for the inputs, CSS custom properties for the border radius, and logical properties for layout direction support.
See the Pen [Stateful multi-range selection groups with :has() [forked]](https://codepen.io/smashingmag/pen/RwBxpjE) by Stephanie Eckles.
Further Reading And Demos
A collection of all demonstrations from this article is available on CodePen. Other authors are already exploring the edges of what :has() enables:
- Bramus Van Damme: Quantity queries for islands of same-class elements, an
:nth-child(An+B [of S]?)polyfill, and styling an element by its child count - Jhey Tompkins:
:has(): The Family Selector - Jen Simmons: Using
:has()As A CSS Parent Selector And Much More - Adrian Bece: Meet
:has, A Native CSS Parent Selector (And More) - Estelle Weyl: CSS
:has() - Manuel Matuzović:
:has(:not())vs.:not(:has())




