Skip to contentSkip to footer

If you want to be kept up to date with new articles, CSS resources and tools, join our newsletter.

CSS functions like :is(), :not() and :has() are powerful tools that make it much easier to select elements specifically. We've written before about how ::where() :is() and :has() make your life easier, as well as how they handle specificity.

When you combine CSS Functions, the nesting of these functions matters. In this post, we'll look at the difference between :has(:not) and :not(:has), and how to approach decoding CSS selectors that use these nested CSS functions.

Our HTML structure

In order to figure out what a given CSS selector does, it's easier if we have a simple HTML structure to work with. Let's use the following structures:

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

<!-- card 2 -->
<div class="card">
  <span />
</div>

You've probably written this kind of code before: There's a container div and inside it an optional image and a span containing some text.

Using CSS, we want to select a card when it does not have an image. For this, we're going to look at the difference between these two CSS selectors and see how they select, or don't select, either of the .card elements.

.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 a specificity of (0,1,1) but they're adding different conditional logic.

When you look at the above selectors, there are three things that can help improve he understanding of what they do:

  1. Add the implicit selectors that have been left out
  2. Break down the selectors into parts
  3. Work from the inside out

Adding implicit selectors

When you have a CSS function or pseudo-selector (both start with a single :), they're expected to be attached to another selector:

  • a:focus
  • div:is(.active)
  • button:active
  • ...etc

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:not(.card:has(img)) {}

Breaking down the selectors into parts

When you break down the selector into its parts, it's easier to see what each part does. Let's break down the first selector by extracting the part inside the CSS function:

.card:has() {} /* Select a .card that contains some other selectors */
*:not(img) {} /* select any element that is not an image */

/* and */

.card:not() {} /* Select a card if some other selectors aren't matched */
.card:has(img) {} /* Select a card that contains an image */

Now we can look the separate parts and see what they do.

Working from the inside out

Eventually we're trying to select the .card, so when looking at the selector inside our CSS function, we can simplify our HTML to the contents of the .card:

<!-- first card -->
<img />
<span />

<!-- second card -->
<span />

Now, we'll take a look at the selectors and compare them to our simplified HTML:

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.

In this case, we looked at the difference between :has(:not) and :not(:has), and how they select elements based on the presence or absence of other elements.

If the above explanation didn't work for you, then we highly recommend taking a look at Manuel's exploration of the same: :has(:not()) vs. :not(:has()).

Build your next project with Polypane

  • Use all features on all plans
  • On Mac, Window and Linux
  • 14-day free trial – no credit card needed
Try for free
Polypane UI