:is(), :where(), :has(), :not()

shohanur rahmanshohanur rahman
16 min read

The :has() pseudo-selector is the only CSS Selector that selects elements based on whether they contain specific descendants. It targets the parent element with one or more descendants that match the specified selector. It can also target siblings or child elements.

The CSS :has() pseudo-selector is considered a functional selector. This is because, to target specific elements, it takes in parameters. These parameters can be an element, a list of elements, or sometimes combinators (+, ~, >). The :has() selector is the perfect selector for targeting elements based on their content. This selector is particularly useful when we want to apply styles to a parent element based on the existence or properties of its child elements.

header:has(h2):has(p.subtitle) h2 {    /* If header contains a h2 and a aragraph with subtitle class */
    margin-bottom: 20px; 
}

body:has(.p-item.is-selected) .cart-button {    /* Add to cart button UI update on product selection */
  background-color: green;
}

/* You can also pass a list. But it behaves differently. It gets true if any selector is matched. */
header:has(h2, p.subtitle) h2 {    
    /* selects the h2 inside header if it contains either h2 or p.subtitle or both */
}

article:has(ol, ul) {    
  /* Matches an <article> that contains either an ordered or unordered list. */
}

But before studying about has() in detail, lets discuss about :is(), :where(), and :not().


:is()

Lets you write multiple selectors in one line.

.example h2,
.example a,
.exmaple span,
.example:hover,
.exmaple:focus,
.example a:hover,
.exmaple a:focus{
    /* is same as the snippet below */
}

.example :is(h2, a, span),
.example:is(:hover, :focus),
.example a:is(:hover, :focus) {
  /* But not same as the code below */
}

.exmaple:is(:hover, :focus) :is(h2, a, span), .example a:is(:hover, :focus){
/* this snippet selects the h2, a and span when .example is hoverded or focused. */ 
}

:is() is a forgiving selector. In compound selector by adding invalid selector (e.g: .1abc; as class can’t start with number), that ruleset stops execution. Such occurrence can make it very difficult to debug. But the safe side of using :is() is even if a selector is invalid, rest of the selectors still remains functional.

.example h2,
.example a,
.exmaple .1abc{
    /* This ruleset will not work. As a result, h2 and a will not get the styles */
}

.example :is(h2, a, .1abc) {
/* even if there is an invalid selector, the rest of the selectors h2,a still gets the styles. */
}

:is() takes the highest specificity among the passed selectors.

.example :is(h2, a, .c1) {
    color: blue;    /* The highest specificity is .c1. So, h2 and a also that specificity. */
}

.example h2,
.example a,
.exmaple .c1{
    color: red;    
    /* h2 and a gets higher specificity (equal to .c1) from :is(). So, stays blue. 
    But .c1 has equal specificity here but declared later in cascade. So, .c1 becomes red. */
}

:where()

The primary difference between :is() and :where() is that :where() has no specificity. Means, for the code below, h3, a, .c1 will all be red. Because every selector inside where has zero specificity. The use case is when you want to keep overwriting styles very easily.

.example h2,
.example a,
.exmaple .c1{
    color: red;    
}

.example :is(h2, a, .c1) {
    color: blue;    
}

:not()

The :not() property in CSS is a negation pseudo class and accepts a simple selector or a selector list as an argument. It matches an element that is not represented by the argument. The passed argument may not contain additional selectors or any pseudo-element selectors.

/* the X argument can be replaced with any simple selectors */
:not(X) {
  property: value;
}

:not(.item__cost) {
  font-family: "Montserrat";    /* all elements without the class .item__cost get Montserrat font. */
}

/* Style every li but those with .different class */
li:not(.different) {
  font-size: 3em;
}

/* do the same using pseudo classes which are considered a simple selector */
p:not(:nth-child(2n+1)) {
  font-size: 3em;
}


:not(::first-line) { /* if we use a pseudo-element selector as argument it will not work */
  color: white;
}

Complex selectors:

ul li:not(:last-of-type) { 
margin-bottom: 20px;    /* Only the last list item should not get a margin bottom */
}

p:not(article *) {}    /* select all <p> that are not descendants of <article> */
h2:not(:where(article *, section *)) {}

code:not(h2 > code) {}    /* Style all code elements except those inside h2 elements */

/* You can also pass a comma-separated list. 
   All <input> elements that are not required and are not buttons will have a blue background. */
input:not(:required, [type="button"]) {
  background-color: blue;
}

:has()

: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. 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. :not() was allowed to accept a list of selectors instead of a single selector. It also has the same specificity behavior noted for :is().

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. So, in p:is(h2 + *), * refers to p. So, we’re selecting paragraphs only if they directly follow h2. And in img:not(h1 + *), * refers to img, we’re selecting images that do not directly follow an h1. But we could just write p:is(h2 + p) and img:not(h1 + img). Why *? No need in this case but we’ll need the concept. Keep reading.

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. By combining :has() and :not(), we can effectively create an :only-of-selector that will match a singleton within a range of siblings. 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 says, “AND not itself(.highlight) a sibling of a previous .highlight.” So, The first part ensures no subsequent repetition and second part ensures that the target selector itself is not being repeated. Thus, * is used to indicate the target selector and create our custom :only-of-selector pseudo-class.

Previous Sibling Selector

So, we checked against previous siblings with :not(), :is(), and :where()and with :has(), we can select and style previous siblings. Now, the behavior we’d like is that when a list item is hovered, it scales up larger, and the elements before and after it also scale up slightly. The remaining non-hovered list items should scale down. All but the hovered list item should also have their opacity lowered.

The desire for the first selector is to match the list item before the one being hovered, which :has() makes possible. Select the list item whose adjacent sibling is being hovered. Then, a basic adjacent sibling selector to get the list item after the hovered one

li:has(+ li:hover),    /* Select list item before the hovered one */
li:hover + li {/* styles */}    /* Select list item after the hovered one */

The third complex selector we’ll create uses our power combo of :has() and :not() but in a new way. We first qualify the selector only to apply when a direct child of the ul (which will be a list item) is being hovered. And if that’s true, we select list items based on excluding the one being hovered and the items before and after the hovered one.

/* When a list item is being hovered, select list items not hovered or before hover or after hover */
ul:has(> :hover) li:not(:hover, :has(+ :hover), li:hover + *)

Not only does this demonstrate selecting a preceding sibling with :has() but also using it to select based on state. The final demonstration will create a more complex example using states with :has().

Selecting Multiple Elements Within A Range

select all elements that follows an h2 as long as its next sibling is an h2.

article h2 ~ :has(+ h2)

select the adjacent sibling of the h2 as long as there is another h2 as a later sibling

article h2 + :has(~ h2)

select all sibling elements following an hr that themselves have a later sibling of an hr

article hr ~ :has(~ hr)

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 styles the top checkbox

/* 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.

label:has(:checked):has(~ label :checked),
label:has(:checked):not(label:has(+ label :checked))

For more examples of “selecting within range type” check reference-03.

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>

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.

Firstly, 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;
}


Breadcrumbs

Breadcrumbs are a handy way of showing what page a user is currently on and where that page fits in the sitemap. Like, if you are on an About page, you might show a list that contains an item with a link to the Homepage and an item that merely indicates the current page:

<ol class="breadcrumb">
  <li class="breadcrumb-item"><a href="/">Home</a></li>
  <li class="breadcrumb-item current">About</li>
</ol>

we can draw a distinction between the two items by adding a separator between them. But we don’t need a separator after the .current item because it is always last in the list and has nothing after it. This is where :has() comes into play. We can look for any child with the .current class using the subsequent child combinator (~) to sniff it out:

.breadcrumb-item:has(~ .current)::after {
  content: "/";
}

Form validation

:has() doesn’t accept pseudo-elements, but it does allow us to use pseudo-classes. We can use this as a light form of validation that we might usually tackle with JavaScript. Let’s say we have a newsletter signup form that asks for an email:

<form>  
  <label for="email-input">Add your pretty email:</label>
  <input id="email-input" type="email" required>
</form>

Email is a required field in this form. Otherwise, there’s nothing to submit! Maybe we can add a red border to the input if the user enters an invalid email address. Try entering an invalid email address, then tab or click to the Password field.

form:has(input:invalid) {
  border: 1px solid red;
}

Changing Color Themes

Dark mode, light mode, high-contrast mode. Customize site’s color theme with CSS.

body:has(select#theme-selection option[value="dark"]:checked) {
  --primary-color: #e43;
  --surface-color: #1b1b1b;
  --text-color: #eee;
}

Select style based on number of children

Imagine you have two-column layout. If the number items in the grid is: 3, 5, 7, 9, etc. — then you’re stuck with an empty space in the grid after the last item.

Adjust the two-column layout with an odd number of children where the first child spans the first row. It would be better if the first item in the grid could take up the two columns in the first row to prevent that from happening. And for that, you’d need to check whether the :last-child element in the grid is also an odd-numbered child:

/* If the last item in a grid is an odd-numbered child */
.grid-item:last-child:nth-child(odd) {}

This can be passed into the :has() argument list so we can style the grid’s :first-child so it takes up the entire first row of the grid when the :last-child is an odd number:

.grid:has(> .grid-item:last-child:nth-child(odd)) .grid-item:first-child {
  grid-column: 1 / -1;
}

Specificity

One of the more interesting aspects of :has() is that its specificity is determined by the most specific element in its argument list. Say we have the following style rules:

article:has(.some-class, #id, img) {
  background: #000;
}

article .some-class {
  background: #fff;
}

We have two rules, both selecting an <article> element to change its background. Which background does this HTML get? You might think it gets a white (#fff) background because it comes later in the cascade. The argument list for :has() includes other selectors, it considers the most specific one in the list to determine the effective specificity. That would be #id in this case. So, the <article> will have a black (#000) background.

:has() is an “unforgiving” selector

That means :has() behaves a lot more like compound selector. As in compound selector is that if any selector in the list is invalid, the entire selector list is invalid, resulting in the entire rule set being thrown out. The same is true of :has(). Any invalid selector in its argument list will invalidate everything else in the list.

a, a::-scoobydoo {
  color: green; /* This doesn't do anything because `::-scoobydoo` is an invalid selector */
}

article:has(h2, ul, ::-scoobydoo) {
    color: green; /* will also do nothing */
}

Remember, :is() and :where() are forgiving, even if :has() is not. That means we can nest either of the those selectors in :has() to get more forgiving behavior. So, if you ever need :has() to work as a “forgiving” selector, try nesting :is() or :where() inside of it.

p:has(:where(a, a::scoobydoo)) {
  color: green;
}

Combining :has() with other relational pseudo-selectors

You can combine :has() with other functional pseudo-class selectors such as :where(), :not(), and :is().

Combing :has() and :is()

For instance, you can check whether any of the HTML headings has at least one <a> element as a descendant:

:is(h1, h2, h3, h4, h5, h6):has(a) {
  color: blue;
}

/* is equivalent to: */
h1:has(a),
h2:has(a),
h3:has(a),
h4:has(a),
h5:has(a),
h6:has(a) {
  color: blue;
}

You can also pass :is() as an argument to :has(). Imagine if we changed that last example so that any heading level that contains an <a> child element or any child element with the .link class is selected:

:is(h1, h2, h3, h4, h5, h6):has(:is(a, .link)) {
  color: blue;
}

Combining :has() and :not()

We can use :has() with the :not() selector. Let’s say you want to add a border to a .card element if it doesn’t contain any <img> element descendant. Sure thing:

.card:not(:has(img)) {
  border: 1px solid var(--my-amazing-color);
}

This checks if the card :has() any image then says: if you don’t find any images, apply these styles.

Let’s select any .post element for images that are missing alt text:

.post:has(img:not([alt])) {
  /* Styles */
}

See what we did here? This time, :not() is in :has(). This is saying: if you find any posts that contain an image without alternative text, apply these styles please and thank you.

Let’s say, you want to select any <div> that has nothing but <img> elements.

div:not(:has(:not(img))) {
  /* Styles */
}

What we’re saying here is: If you find a <div> and there’s nothing in there but one or more images.

Where order matters

Notice how changing the order of our selectors changes what they select. We talked about the unforgiving nature of the :has()argument list, but it’s even less forgiving in the examples we’ve looked at that combine :has() with other relational pseudo-selectors. Let’s look at an example:

<!-- card 1 -->
<div class="card">
  <img />
  <span />
</div>

<!-- card 2 -->
<div class="card">
  <span />
</div>
.card:has(:not(img)) {}
.card:not(:has(img)) {}

Both of these CSS selectors are selecting the element with a class of card, and both have same specificity.

When you have a pseudo-selector, they're expected to be attached to another selector: If you don't attach them directly to a selector, the CSS parsing logic adds an implicit universal selector (*) in front of the CSS function or pseudo-selector:

  • a :focus => a *:focus

  • div :is(.active) => div *:is(.active)

  • button :active => button *:active

You can see how the pseudo-selectors are now applied to the children of the element, and not the element itself. If you use :has(), then the selector you put inside of it is already applied to the child elements of the element you're selecting. This is different from :is() and :not(), which are applied to the element itself.

That means that when we add implicit selectors, we can add the implicit universal selector in front of the nested :not() selector, and repeat the selector for the nested :has() selector:

.card:has(:not(img)) {}
.card:has(*:not(img)) {}
.card:not(.card:has(img)) {}

The first nested selector: *:not(img)

  • img is an image so *:not(img) does not match

  • span is not an image, so *:not(img) does match

Since the span is in our HTML structure for both cards, we can simplify and rewrite *:not(img) to span. The full first selector now becomes .card:has(span): select any card that contains a span element. Both HTML examples are a card that contain a span element, so both cards get selected.

The second nested selector: .card:has(img)

  • The first card contains an image, so .card:has(img) does match

  • The second card has no image, so .card:has(img) does not match

In other words, if you look at that :has() we get a "true" for the first card and for the second card we get "false".

If we then put that back into the full selector we get .card:not(true) and .card:not(false).

:not() inverts the boolean values, so out of the two HTML cards, the first is not selected and the second is, because it doesn't have an image.

Conclusion

When you're trying to figure out what a CSS selector does, it can be helpful to break it down into its parts and work from the inside out. This makes it easier to see what HTML each part of the selector selects, and you can then recombine them find the full HTML that the selector matches against.

References:

https://www.youtube.com/watch?v=3ncFpP8GP4g

https://css-tricks.com/almanac/pseudo-selectors/h/has/

https://www.smashingmagazine.com/2023/01/level-up-css-skills-has-selector/

https://polypane.app/blog/decoding-css-selectors-has-not-vs-not-has/

https://www.youtube.com/watch?v=axkefJvfS9U

0
Subscribe to my newsletter

Read articles from shohanur rahman directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

shohanur rahman
shohanur rahman