If you’ve written CSS before, you’ve used pseudo selectors.
Typically, we’d use them to style :hover events or select every :nth-of-type()
In recent years CSS has become a lot more powerful, and with that, we now have many more pseudo-selectors available to use.
Let’s explore some functional pseudo-selectors together and see how to use them to enhance our front-end code.
Functional pseudo-classes
While there are a wide range of pseudo-classes, I want to focus on the functional ones today.
:is
:is
works very similar to a regular CSS class list at first glance
:is(a) {
// this just provides styles to your <a> element
}
One of its main benefits is that you can group CSS classes to form more readable and maintainable conditions.
article :is(h1,h2,h3,h4,h5,h6) {
font-weight: bold;
}
// replaces
article h1, article h2, article h3, article h4, article h5, article h6 {
font-weight: bold;
}
For deep nesting, this can make your CSS significantly easier to understand, simplifying editing at a later date.
article .prose h1,
article .prose h2,
article .prose h3,
article .prose h4,
article .prose h5,
article .prose h6,
article aside h2,
article aside a,
article .call-to-action h2 {
font-weight: bold;
}
// becomes
article :is(.prose, aside, .call-to-action) :is(h1, h2, h3, h4, h5, h6, a) {
font-weight: bold;
}
You may be thinking that CSS has another solution that improves readability in a similar way: nesting. That’s true, and in this context, you can use them interchangeably, though the syntax is still shorter.
article :is(h1,h2,h3,h4,h5,h6) {
font-weight: bold;
}
//is the same as
article {
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
}
}
However deeper nesting using nested CSS can become complex quickly. Sometimes it makes sense to use :is to help avoid this complexity.
article :is(.prose, aside, .call-to-action) {
padding: 1rem;
}
article :is(.prose, aside, .call-to-action) :is(h1, h2, h3, h4, h5, h6, a) {
font-weight: bold;
}
//With nested CSS
article {
.prose, aside, .call-to-action {
padding: 1rem;
h1, h2, h3, h4, h5, h6, a {
font-weight: bold;
}
}
}
:is
also provides a specificity modifier you don’t get with nested css.
If you write:
article :is(h1, h2, h3, h4, h5, h6, .bold, #article-heading)
Every selector within the:is
would be treated as if it had the same specificity value as the ID. With :is,
the highest value applies to all other values within the :is
. This does not apply to nested CSS.
:not
It does exactly what you think it does.
The :not
selector grabs everything except the classes you define:
// Select all btn classes which are not links
.btn:not(a) {
...
}
Like the other pseudo-classes, you can negate multiple classes if you choose:
article :not(h1, h2, h3, h4, h5, h6) {
font-family: inter;
}
This can be powerful when combined with other pseudo-selectors:
li:not(:last-child) {
border-bottom: 1px solid #ccc;
}
:where
Using the :where pseudo-class is great for creating low-specificity CSS.
If you create a library or plugin that’s going to be used by other people and you want them to style it themselves, but don’t want it to appear ugly when they first set it up, :where
is the way to do that.
For example, let’s style all our links with an underline using :where
// Select all links, with a specificity of 0
:where(a) {
color: rebeccapurple;
text-decoration: underline;
}
With this approach, we can override our link’s default styles without having to create difficult to maintain CSS:
a {
text-decoration: none;
}
If we didn't use :where
above, import order would matter if we wanted to override this. But because we planned ahead, we can just use a standard a
tag.
Without using :where
we’re stuck with options that are much harder to work with:
// We'd be forced to do something like this to increase specificity
* > a {
text-decoration: none;
}
// Or add the ever-dreaded !important which we'd have to fight with later
a {
text-decoration: none !important;
}
:where also has the same benefits of class grouping that :is does but without the specificity, so you can do something like this and then override it easily later.
article :where(h1, h2, h3, h4, h5, h6) {
font-weight: bold;
}
:has
One of the most powerful pseudo selectors is :has,
which gives you the option to style a tag or class based on other classes or tags associated with it.
// if active items have a different state, we can hide styles on previous elements
li:has(+ li.active) {
border-bottom: none;
}
An incredible benefit :has
brings is the ability to create parent selector functionality. This gives you a wide variety of options to style the parent based on the state of the children.
// The form would show an error state if any of the inputs are empty
form:has(input:empty) {
border: var(--error);
}
// Remove padding for an article h1 only when it's followed by a subtitle
article h1:has(+ .subtitle) {
padding-bottom: 0;
}
// add borders if a table cell has another table cell after it
table td:has(+ td) {
border-right: 1px solid black;
}
You can also combine this with the :not
selector to select elements that don’t have specific children.
// Add additional styles if the form button is hidden
form:not(:has(> button)) { ... }
Benefits of Pseudo-Classes
Readability
One of the benefits of all of these pseudo-selectors is that they can act similarly to nested CSS. It’s possible to use these to make your code more readable.
:is(a, button, .link) .small {
font-size: 0.875rem;
}
// is the same as standard css
a .small, button .small, .link .small {
font-size: 0.875rem;
}
// and is also the same as nested css, but smaller
a, button, .link {
.small {
font-size: 0.875rem;
}
}
Using :is
this way is effectively the same as using nested CSS, but if you need lower specificity you could use :where, or :not if you want to exclude (instead of include) some classes.
Functionality
:not
and :has
provide new options which weren’t possible before, allowing you to style more dynamically and provide better experiences while simplifying your code.
Before these options were available, the only solution was to style the code using JavaScript. While this technically allows you to achieve your styling goals, it’s not ideal. Mixing CSS operations into JS files makes it much harder to maintain long-term because it adds a layer of abstraction to your solution, while the built-in CSS option is much simpler.
While :is
and :where
don’t provide as much new functionality, they still allow you to write more understandable CSS with less ambiguity or workarounds, making maintenance significantly easier.
Summing up
Modern CSS allows us to be much more flexible with our styles, all while writing less code.
Simplifying CSS and removing the need to compensate, either by writing extra CSS or styling through JS, means our CSS files are more explicit and easier to maintain in the long term.
They may not be needed often, but they’re an incredibly important part of your front-end toolbelt.
Have you found any interesting ways to use functional pseudo-classes? Send us a post on X or message us on LinkedIn and show me what cool things you’ve made.