Level Up Your CSS Skills With The :has() Selector

Level Up Your CSS Skills With The :has() Selector

Using :has() gives us the ability to “look ahead” with CSS and style a parent or ancestor element. Then, we can broaden the selector to target one or more siblings or children. By considering element states or positions, we can style nearly any combination of elements as unique singles or ranges.

Note: At present, support for :has() is rising, with it being available as of Safari 15.4 and Chrome/Edge 105. It is also behind a flag in Firefox as of version 103. Until full support is available, check out this tip for supporting :has today from Bramus Van Damme.

How :has() Works With Combinators And Pseudo-Classes

To best understand how the advanced selectors we’ll be creating work, we’ll quickly review the most relevant combinators and pseudo-classes.

A “combinator” is a special character that denotes the type of relationship between selector parts. Here are the core combinators to know:

  • space character: the descendent combinator matches a direct or nested child;
  • >: the direct child combinator matches only top-level, un-nested children;
  • +: the adjacent sibling combinator matches only the very next sibling;
  • ~: the general sibling combinator matches one or more siblings following the base selector.

The first stage of creating complex selectors is to append a pseudo-class to one or more parts. A “pseudo-class” defines a special state of an element, like :hover, and has the format of a single colon followed by the name. The :has() pseudo-class is considered functional since it accepts a parameter. Specifically, it accepts a list of selectors, whether they be simple like img or complex with combinators like img + p.

However, :has() is one of four functional pseudo-classes, with the others being :is(), :where(), and :not(). Each of them accepts a selector list with a few other unique features.

If you’ve already used :is() and :where(), it’s likely been to manage specificity. Using :is() means the selector in the list with the highest specificity gives the entire selector its weight. While using :where() lends the entire selector list zero-specificity, making it easily overruled by later rules in the cascade.

Additionally, :is() and :where() have the extra special ability to be forgiving selectors. This means you may include (purposely or not) selectors the browser doesn’t understand, and it will still process the parts it does understand. Without this forgiving behavior, the browser would discard the entire rule.

The other benefit of both :is() and :where() is to create succinct, complex selectors. This is especially handy when using combinators and affecting multiple siblings or descendants, for example, article :is(h1, h2, h3).

Our last pseudo-class, :not(), has been available in CSS for the longest. However, alongside Selectors Level 4 when :is() and :where() were released, :not() was enhanced. This happened when it was allowed to accept a list of selectors instead of a single selector. It also has the same specificity behavior noted for :is().

Finally, we need to know about an underused, incredibly powerful feature of :is(), :where(), and :not() that we’ll be using to make our advanced :has() selectors. Using the * character within these selectors — which normally in CSS is the “universal selector” — actually refers to the selector target. This allows checking the preceding siblings or ancestors of the selector target. So, in img:not(h1 + *), we’re selecting images that do not directly follow an h1. And in p:is(h2 + *), we’re selecting paragraphs only if they directly follow h2. We’ll be using this behavior for our first demo next.

Polyfill For :only-of-selector

While :only-of-type is a valid pseudo-class, it only works to select within elements of the same element type. Given .highlight:only-of-type, no matches would be made in the following HTML because the class has no effect on reducing the scope.

<p>Not highlighted</p>
<p class="highlight">.highlight</p>
<p>Not highlighted</p>

If there was only one paragraph with the highlight class within a parent, it might falsely appear to be working. But in that case, it’s because the root element type the class is attached to is a paragraph, so it matches as true since there are no sibling paragraphs.

By combining :has() and :not(), we can effectively create an :only-of-selector that will match a singleton within a range of siblings based on a class or other valid selector.

We ultimately want our selector to match when there are no matching siblings that exist before or after the target.

A strength of :has() is testing for what follows an element. Since we want to test any number of siblings that follow, we’ll use the general sibling combinator ~ to create the first condition.

.highlight:not(:has(~ .highlight)

So far, this gives us the match of “highlights that do not have sibling highlights following it.”

Now we need to check prior siblings, and we’ll use the ability of :not() on its own to add that condition.

.highlight:not(:has(~ .highlight)):not(.highlight ~  *)

The second :not() condition is an AND clause to our selector that says “AND not itself a sibling of a previous highlight.”

With that, we have polyfilled the non-existent :only-of-selector pseudo-class!

To resolve this, we need to add a complex AND condition using :not() to exclude items that are not between [data-range="end"] and [data-range="start"], in that order.

On its own, this part of the selector reads as: “do not select items that follow [data-range="end"] which also have a later sibling of [data-range="start"].”

/* Note: this needs appended on the previous selector, not used alone */
:not([data-range="end"] ~ :has(~ [data-range="start"]))

In total, this makes for an admittedly long but very powerful selector that wasn’t possible before :has() without also using JavaScript due to the previous lack of the “look ahead” and “look behind” abilities in CSS.

/* Select all between a range */
[data-range="start"] ~ :has(~ [data-range="end"]):not([data-range]):not([data-range="end"] ~ :has(~ [data-range="start"]))
Keep in mind that just like other selectors, you can use :has() when you construct a selector within JavaScript. The ability to select previous siblings, ancestors and the other features we’ve learned will also make your JS selectors more efficiently powerful!

See the Pen Multi-range element selectors with :has() [forked] by Stephanie Eckles.

Linear Range Selection Based On State

Let’s pull together some of the qualities of :has() selectors and combinators we’ve learned to make a star rating component.

The underlying “star” will be a radio input, which will give us access to a :checked state to assist in developing the selectors.

<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>

As shown in the following video preview, when a user hovers over the outlined stars, then the range from the start (left-most) to the hovered star should fill in with color. On selection, when the star radio is checked, the star and labeling number scale up in size and keep the fill color. If the user hovers over stars after the checked star, the range should fill in the stars up to the hover. If the user hovers stars before the checked star, the range should fill in only up to the hovered star, and stars between the hover and previously checked star should have the fill color lightened.

That’s a lot of ranges to keep track of, but with :has(), we can break them into segmented selectors real quick!

The following selector series applies to all states where we want a star or range of stars to fill in for or up to the :checked star. The rule updates a set of custom properties that will affect the star shape, created through a combo of the ::before and ::after pseudo-elements on the label.star.

Altogether, this rule selects the range of stars between the first star and the star being hovered, or the first star and the star with a checked radio.

.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;
}

Next, we want to lighten the fill color of stars in the range between the star being hovered and a later checked star, and checked stars that follow the hovered star.

/* 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;
}

As far as state selectors go for our star rating component, that’s all there is to it!

The CodePen demo has a few extra tricks on how the component is created using CSS grid, custom properties, and clip-path. For accessibility, it also ensures color isn’t the only indicator by scaling up the checked star. And it handles for high contrast themes (aka “forced colors”) by supplying values from the system colors palette to ensure the :checked star fill is visible. Additionally, the transitions are shortened when a user prefers reduced motion.

See the Pen Star Rating Component with :has() [forked] by Stephanie Eckles.

Stateful Multi-Range Selection Groups

Whereas the star rating component showed a dynamic style change based on state, the availability of stateful elements also makes it easier to use :has() for creating visual boundaries.

Our earlier multi-range selectors relied on manually adding “hooks” into the markup to correctly style ranges without leaking into the in-between areas. But if we have a field set containing checkboxes, we can once again use the :checked state to clearly identify boundaries around checked and unchecked items.

In this preview video, as checkboxes are selected, they receive a border and green background to create the visual boundary. Thanks to :has(), that boundary grows to appear to wrap groups of checked items so that the visual box seems as though it's around the whole group. The first item (or a singleton) gets round top corners, and the last item (or a singleton) gets round bottom corners as well as a slight shadow.

We need to create rules to handle the top, middle, and bottom appearance based on where the item falls within the set. Single items should receive all three styles.

Our HTML is set up to wrap each checkbox input with its label, so all of our selectors will begin by matching against label:has(:checked) to see if the label contains a checked input.

To determine either the first or single item in the set, we need to add the condition that it is not following a previous item with a checked input. This rule will style the top appearance.

/* First checked item in a range
 OR top of a single checked item */
label:has(:checked):not(label:has(:checked) + label)

To determine either the last or single item in the set, we flip the previous condition to check that it is not followed by a checked input. This rule will style the bottom appearance.

/* Last checked item in a range
 OR bottom of a single checked item */
label:has(:checked):not(label:has(+ label :checked))

For the middle appearance, we’ll create a rule that actually captures the group from start to finish since all of the items in the rule should receive a background color and side borders.

We could simply use label:has(:checked) for this selector given the context. However, we’re learning how to select and style ranges, so to complete our exercise, we’ll write the expanded selectors.

The logic represented in the first selector is “select labels with checked inputs that are followed by sibling labels containing checked inputs,” which captures all but the last item in the range. For that, we repeat the selector we just created for styling the last checked item in the range.

/* Range of checked items */
label:has(:checked):has(~ label :checked),
label:has(:checked):not(label:has(+ label :checked))

This CodePen demo also shows off accent-color for changing the checked input color and uses custom properties for managing the border radius. It also uses logical properties.

See the Pen Stateful multi-range selection groups with :has() [forked] by Stephanie Eckles.

More Resources On Writing :has() Selectors

You can explore all of the demonstrations we reviewed in my CodePen collection.

Other folks have started experimenting with what’s possible using :has(), and I encourage you to check out these resources for even more ideas. As with all recently released features, the field of opportunity is wide-open, and we all benefit when we share our learnings!

  • Bramus Van Damme has a few explorations of complex selectors using :has():
    • Quantity Queries for “islands of elements” with the same class, thanks to CSS :has()
    • A :nth-child(An+B [of S]?) polyfill thanks to CSS :has() and :not()
    • Style a parent element based on its number of children using CSS :has()
  • Jhey Tompkins reviews both practical and fun use cases in “:has(): The Family Selector”
  • Jen Simmons looks at the relationship of :has() to combinators and showcases more demos in “Using :has() As A CSS Parent Selector And Much More”
  • Adrian Bece considers even more possibilities in “Meet :has, A Native CSS Parent Selector (And More)”
  • Estelle Weyl demystifies more about this selector’s behavior in “CSS :has()
  • Manuel Matuzović clarifies the important difference between “:has(:not()) vs. :not(:has())