In CSS, pseudo-classes like :hover
and :first-child
apply styles mostly based on an element's state or position. There are also "functional pseudo-classes," among which :not
is the oldest and very likely still the most well-known. However, :is()
and :where()
are two less popular ones that also allow for simplifying complex CSS selectors. In this post, I will explore how these pseudo-classes work. As a bonus, I will compare them to using CSS Nesting and reflect on the necessity of using complex selectors.
And if now you're still a bit curious about the title of this post, the pseudo-classes discussed give me a unique opportunity to reference The Pixies in a technical article...
Understanding :is()
The :is()
pseudo-class simplifies the process of writing complex selector patterns in CSS. The question whether you should be using such patterns in the first place is another one. I'm briefly gonna come back to that later. For now simply consider the following CSS rules where you want to apply the same styles to various elements within article sections:
article h1 a,
article .example a,
article #unique a {
color: darkblue;
}
Using :is()
, this can be streamlined into:
article :is(h1, .example, #unique) a {
color: darkblue;
}
This not only reduces redundancy but also enhances readability. But there is one more thing: it's important to understand how :is()
interacts with CSS specificity:
Specificity in :is()
Specificity determines which styles are applied when more than one rule could apply to an element. It's calculated based on the types of selectors used in a CSS rule:
- ID selectors are the most specific (count as 100).
- Class selectors (
.example
), pseudo-classes (like:disabled
), and attribute selectors (likea[href*="example"]
) are less specific (count as 10 each). - Type selectors (like
h1
) and pseudo-elements (like::first-line
) are the least specific (count as 1 each).
When using the :is()
pseudo-class, the specificity of the :is()
selector itself is considered to be the specificity of the most specific selector inside its parentheses. This means that it adopts the highest specificity value from its arguments.
In the example above, the specificity of the :is()
selector is determined by the highest specificity among the included selectors, which in this case is the ID selector #unique
with a specificity count of 100. Therefore, the entire rule article :is(h1, .example, #unique) a
will have a specificity count of 102 (100 from #unique
, 1 from article
and 1 from a
).
This configuration means that even though h1
and .example
have lower individual specificities (1 for type selectors and 10 for class selectors, respectively), within the :is()
they effectively adopt the specificity of #unique
, which is 100.
Consequently, any CSS rule targeting article :is(h1, .example, #unique) a
will have a higher specificity than other rules targeting article h1 a
or article .example a
alone, even if those rules are defined later in the CSS. This higher specificity applies unless those competing rules also include an ID selector or are within a higher specificity context.
Exploring :where()
The :where()
pseudo-class functions very similarly to :is()
, with one crucial difference: it has 0 specificity. Using the same set of selectors, we can rewrite the example like this:
article :where(h1, .example, #unique) a {
color: darkblue;
}
Specificity in :where()
The specificity calculation for CSS rules using :where()
is straightforward. The original first rule, article h1 a
, has a specificity of 1 from the element selector h1
plus 1 from each of the element selectors (article
and a
), totaling a specificity of 3. In contrast, this rule inside :where()
, article :where(h1) a
, maintains a specificity of 2, which comes solely from the element selectors article
and a
. This is because the :where(h1)
part contributes zero to the specificity.
Therefore, if both rules target the same a
within an h1
element within article
, the original rule with higher specificity will override the second rule. This setup illustrates how :where()
allows for the application of styles that remain easy to override, making it a potential choice for applying broad, default styles that can be easily customized or overridden elsewhere in the stylesheet.
Bonus: Simplifying CSS with Nesting
CSS nesting presents another strategy for organizing and simplifying your stylesheets, though it isn't fully supported in all browsers yet. It allows you to group styles in a way that reflects the hierarchical structure of your HTML. To users of some CSS preprocessors it should be looking very familiar.
Using nesting, we can simplify the example from above like so:
article {
h1,
.example,
#unique {
a {
color: darkblue;
}
}
}
This enhances readability by mirroring the HTML document's structure within the stylesheet, making it easier to see how styles are applied. It also simplifies maintenance, as changes to styles or parent elements need only be made in one place, reducing inconsistencies. Additionally, nesting minimizes code repetition by eliminating the need to repeatedly specify parent selectors. It also doesn't affect the specificity of selectors, which helps in avoiding unintended style overrides.
Complex Selectors in Modern CSS
Earlier in this article, I hinted at the question of whether using complex CSS selectors is advisable in the first place. While they are powerful tools, they carry inherent challenges. It can be difficult to manage them in large projects, as they often lead to brittle CSS that is hard to refactor. And while modern browsers are optimized for CSS parsing, overly complex selectors can still impact rendering performance, particularly in large DOM structures. Additionally, managing specificity with complex selectors, as seen with :is()
and CSS nesting, can become cumbersome. This often leads to "specificity wars", where styles unintentionally override each other due to conflicting specificity levels.
The landscape of styling in web development has significantly evolved over the last decade. Several modern methodologies and tools allow you to build complex UIs while making it easier to avoid these pitfalls:
- BEM encourages a flat structure with minimal nesting and specificity, making styles easier to read and maintain. It significantly reduces the risk of unintended style overrides and enhances modularity.
- CSS Modules are particularly useful in component-based architectures like React or Vue, where they allow for the local scoping of CSS by automatically creating a unique class name for each style. This method avoids global scope issues and helps in maintaining modular and reusable components without the fear of styles bleeding across components.
- CSS-in-JS also scopes styles to components, reducing global conflicts and specificity issues, and offers dynamic styling capabilities based on state or props.
- Utility-First frameworks, such as Tailwind CSS, provide a vast set of utility classes that can be composed directly in the HTML. This minimizes the need for custom CSS and complex selectors.
- Component Libraries come with a set of pre-designed and pre-styled components that can be directly used in projects, though they can also be headless. They help standardize UI elements across applications and reduce the need for custom CSS.
Final Thoughts
:is()
and :where()
have certainly added to the power of CSS, but they require careful consideration of specificity. Due to my use of the approaches listed above, I have rarely needed to write more complex selectors in the last couple of years, except maybe occasionally for data tables or lists. And if I had to do so now, I would probably opt for CSS nesting if possible, unless there's a need to handle specificity very consciously. But I generally try to avoid situations where managing specificity becomes a major concern.
If you've found these pseudo-classes useful in your projects, please share your experiences in the comments.
(Cover image on top by Maik Jonietz on Unsplash)
Top comments (0)