CSS Group Selector: :where() and :is()

If you've been writing CSS for a while, you've probably encountered the "specificity wars" – those frustrating battles where one CSS rule stubbornly refuses to be overridden by another. You've also likely wished for a simple way to group selectors without making them overly powerful.

Enter :where() and :is() - two modern CSS pseudo classes that are abolute game-changers for writing clean, maintainable, and highly flexible stylesheets.

At their core, :where() and :is() are powerful grouping selectors, They both solve the age-old problem of writing long, repetitive lists of selectors, making your code cleaner and more maintainable.

But while they look similar, they serve two very different purposes. The crucial difference comes down to one word: specificity. Understanding this difference is the key to unlocking their full potential.

The Problem: Specificity Bloat

Specificity is a measure of how specific a CSS selector is. The more specific a selector, the more weight it carries when the browser decides which styles to apply. This can lead to situations where you have to write increasingly specific selectors just to override existing styles, resulting in bloated and hard-to-maintain CSS.

Let's briefly revisit the problem. Imageine you want to apply a base style to all headings within a secific content area:

.article h2,
.article h3,
.article h4 {
font-family: serif;
color: #333;
}

This works, but the selector .article h2 has a specificity of 0-1-1 (0 IDs, 1 class, 1 element). If you later want to make one h2 a different color with a utility class like .text-blue-500, you might find it doesn't work because the .article h2 rule is more specific. You'd be forced to write a more complex selector or resort to !important - both of which lead to messy, hard-to-manage CSS.

Meet :where(): The Specificity Zero Hero

The :where() pseudo class lets you group multiple selectors into a single rule, but with a revolutionary feature: it reduces the specificity of. the selectors inside it to zero. This means that no matter how specific the selectors are, when used inside :where(), they won't contribute to the overall specificity of the rule.

This makes it the perfect tool for creating base styles, themes, or resets that you want to be easily overridden later. Here's how you can use :where() to apply base styles to headings:

.article :where(h2, h3, h4) {
font-family: serif;
color: #333;
}

The Problem: The Repetition Monster

When you want to apply the same styles to multiple selectors, you often end up writing long, repetitive lists. This not only clutters your CSS but also makes it harder to maintain. For example, consider the following CSS that styles various button types:

.button-primary,
.button-secondary,
.button-tertiary {
padding: 10px 20px;
border-radius: 5px;
font-size: 16px;
}

Meet :is(): The Grouping Assistant

The :is() pseudo class solves this problem of repetition by letting you group multiple selectors into a concise, readable list. It works like this:

button:is(.button-primary, .button-secondary, .button-tertiary) {
padding: 10px 20px;
border-radius: 5px;
font-size: 16px;
}

This is much cleaner and easier to maintain. Crucially, :is() takes on the specificity of its most specific argument. In this case, that's .button-primary, which has a specificity of 0-1-1. This means that if you later want to override the styles for .button-primary specifically, you can do so without any issues.

:where() Example 1: Creating a "Prose" Typography System

Many projects use a typography plugin to style raw HTML that comes from a Markdown file or a CMS. You wrap your content in a container with a class like .prose, and the plugin automatically styles all the headings, paragraphs, lists, and other elements inside it.

Here's how such a plugin uses :where() to apply automatic spacing that is easy to override:

/* This rule applies a top margin to every element that follows a sibling */
.prose
*:where(:not(.not-prose, .not-prose *))
+ *:where(:not(.not-prose, .not-prose *)) {
margin-top: 1.5em;
}

:where() Example 2: Building a Flexible Dark modern

Another fantastic use for :where() is theming. Let's say you switch to dark mode by adding a .dark class to your body element.

.prose {
/* ...default light mode color variables are defined here */
/* This nested rule handles the dark mode override */
&:where(.dark, .dark *) {
/* ...dark mode color variables are defined here */
--prose-color: var(--color-gray-300);
--prose-heading-color: var(--color-white);
}
}

:where() Example 3: Tailwind CSS's dark Variant with :where()

A perfect example of :where() in action is Tailwind CSS's implementation of the dark variant.

@custom-variant dark (&:where(.dark, .dark *));

Here's how it works:

  1. The @custom-variant directive defines a new variant called dark. This is a Tailwind-specific feature that allows you to create custom variants for your utility classes.
  2. (&...: The & represents the element that has the dar: utility on it (e.g., the element with `class="dark:bg-black").
  3. :where(.dark, .dark *): This is the condition. The dark: utility will only apply if the element(&) also matches this selector. This means that either the element itself has the dark class, or it is a descendant of an element with the dark class.