Mastering CSS Pseudo-Classes: :is(), :where(), and :has() – A Guide for Developers with JavaScript Insights
Published by: AT Studio
Published:
Summary: Is CSS a programming language, yes, no, maybe so? Today, we’ll explore how pseudo-classes like :is(), :where(), and :has() bring JavaScript-like logic to CSS, pushing it closer to a programmatic powerhouse.
In this Article:
Related Topics
:is() The Selector Streamliner
The :is() pseudo-class simplifies CSS by grouping multiple selectors into a single rule. It matches any element that satisfies at least one selector in its list, functioning like a logical "OR." In JavaScript, it’s akin to:
if (element.tagName === 'H1' || element.tagName === 'H2' || element.tagName === 'H3') {
  // do something
}Style multiple heading levels with one rule
<style>
  /* Applies color: navy to all <h1>, <h2>, and <h3> elements */
  :is(h1, h2, h3) {
    color: navy;
  }
</style>
<h1>Main Title</h1>
<h2>Subtitle</h2>
<h3>Section Heading</h3>The rule translates to:
if (element.tagName === 'H1' || element.tagName === 'H2' || element.tagName === 'H3') {
  element.style.color = 'navy';
}Notice that the specificity of the :is() is the same as the specificity of the most specific selector in the list. For example
/* Specificity: 0-1-0, since the most specific selector in the list is .h1 class, which is 0-1-0, WINS! */
:is(.h1, h2, h3) {
  color: navy;
}
/* Specificity: 0-0-1, since all selectors are types, which is 0-0-1 */
:is(h1, h2, h3) {
  color: cyan;
}Unlike :where(), which always has zero specificity, :is() adopts the specificity of its most specific selector. This makes :is() perfect when you need grouped selectors to retain their weight in specificity battles.
:where() The Zero-Specificity Helper
The :where() pseudo-class also groups selectors like :is(), matching elements that satisfy any selector in its list. However, its specificity is always zero, making it perfect for default styles that can be easily overridden. The matching logic is similar to :is() in JavaScript:
if (element.tagName === 'BUTTON') {
  // do something
}NOTE: CSS’s zero-specificity twist adds a layer of flexibility JavaScript can’t mimic directly.
<style>
  /*
   Overrides the :where() styles even if it is defined above the :where() due to higher specificity 0-0-1, turning the button red with blue border.
   */
  button {
    border: 1px solid blue;
    background: red;
  }
  /* 
   Applies a reset style (border: none; background: transparent) to all <button> elements with zero specificity 0-0-0. 
   */
  :where(button) {
    border: none;
    background: transparent;
  }
</style>
<button>Default Button</button>
<button>Custom Button</button>
The rule :where(button) is similar to:
if (element.tagName === 'BUTTON') {
  element.style.border = 'none';
  element.style.background = 'transparent';
}
But unlike JavaScript, :where() ensures these styles can be overridden easily in CSS due to its zero specificity. This means you can apply a default style without worrying about it being too specific and hard to override later.
Personally, I use :where() on CSS resets, as it allows me to set default styles without worrying about specificity conflicts. Use :is() for more specific cases where I want to apply styles to multiple elements without repeating myself.
:has() The Relational Selector
The :has() pseudo-class allows you to target elements based on their descendants, offering a powerful way to style elements depending on what they contain. It’s like having a built-in querySelector in CSS, enabling structural styling without extra classes or JavaScript. Think of it as a conditional check, roughly equivalent to this JavaScript logic:
if (element.querySelector('ul')) { 
  //do something 
}
It can style a parent or a sibling based on a child’s presence or state:
<style>
  /* 
   Applies a lightblue background to any div containing a <details> element, highlighting the parent based on its child. 
   */
  div:has(details) {
    background-color: lightblue;
  }
  /*
   Turns the first <p> red inside a div, but only if that div contains an open <details> element.
   */
  div:has(details[open]) p:first-of-type {
    color: red;
  }
</style>
<div>
  <details>
    <summary>:has() example</summary>
    <p>
      the parent element style will change since it has the details child element
    </p>
  </details>
</div>
<div>
  <p>expand the details affects this paragraph</p>
  <p>expand the details does not affect this paragraph</p>
  <details>
    <summary>Another example</summary>
    <p>
      the parent element style will change since it has the details child element
    </p>
  </details>
</div>
The second rule,
div:has(details[open]) p:first-of-type {
  color: red;
}
mirrors this JavaScript:
const element = document.querySelectorAll('div');
element.forEach((el) => {
  const open_details = el.querySelector('details[open]');
  if (open_details) {
    const first_p = el.querySelector('p:first-of-type');
    if (first_p) {
      first_p.style.color = 'red';
    }
  }
});
same as :is(), :has() specificity is the same as the most specific selector in the list.
Key Takeaways
Look closely, and you’ll see how these pseudo-classes echo JavaScript logic:
:is()groups selectors with the specificity of the most specific one, like an || in JS.:where()groups with zero specificity, ideal for defaults or resets.:has()styles based on descendants, mirroring querySelector.
CSS isn’t just for styling anymore — it’s gaining the logic and power of a programming language. With :is(), :where(), and :has(), you can write less code, keep your HTML lean, and build stylesheets that are as smart as they are simple.
Closing Thoughts
In the craft of web development, there’s a timeless truth: mastering the basics is the ultimate sophistication. It’s not about flashy tricks or complex hacks—it’s about wielding the fundamentals with such skill that everything else falls into place. This is especially true for CSS. Your proficiency in CSS directly shapes the complexity of your HTML. Master the :is(), :where(),:has(), and other basics, and you’ll sidestep the chaos of "div soup" and class names so long they could fill a novel.
Under the Hood
For Geeky CSS nerds, here is the Blink (Rendering Engine used in Chromium based browsers like Chrome and Opera) and Webkit (Rendering Engine for Webkit-based browsers like Safari) implementation of :is(), :where(), and :has() behind the scenes.
Blink implementation:
inline unsigned CSSSelector::SpecificityForOneSelector() const {
  // ...
  switch (Match()) {
    case kId:
      return kIdSpecificity;
    case kPseudoClass:
      switch (GetPseudoType()) {
        case kPseudoWhere:
          return 0;
        // ...
        case kPseudoIs:
          return MaximumSpecificity(SelectorList());
        case kPseudoHas:
          return MaximumSpecificity(SelectorList());
        // ...
      }
  }
}
Webkit implementation:
SelectorSpecificity simpleSelectorSpecificity(const CSSSelector& simpleSelector, IgnorePseudoElement ignorePseudoElement)
{
    // ...
    switch (simpleSelector.match()) {
    // ...
    case CSSSelector::Match::PseudoClass:
        switch (simpleSelector.pseudoClass()) {
        case CSSSelector::PseudoClass::Is:
            return maxSpecificity(simpleSelector.selectorList(), IgnorePseudoElement::Yes);
        case CSSSelector::PseudoClass::Has:
            return maxSpecificity(simpleSelector.selectorList());
        case CSSSelector::PseudoClass::Where:
            return 0;
        }
        // ...
    }
}