Guide to Advanced CSS Selectors – Part One

This is episode #24 in a series examining modern CSS solutions to problems I’ve been solving over the last 13+ years of being a frontend developer.

Whether you choose to completely write your own CSS, or use a framework, or be required to build with…


This content originally appeared on Modern CSS Solutions for Old CSS Problems and was authored by Stephanie Eckles

This is episode #24 in a series examining modern CSS solutions to problems I've been solving over the last 13+ years of being a frontend developer.

Whether you choose to completely write your own CSS, or use a framework, or be required to build within a design system - understanding selectors, the cascade, and specificity are critical to developing CSS and modifying existing style rules.

You're probably quite familiar with creating CSS selectors based on IDs, classes, and element types. And you've likely often used the humble space character to select descendants.

In this two-part mini-series, we'll explore some of the more advanced CSS selectors, and examples of when to use them.

Part One (this article):#

Part Two:#

CSS Specificity and the Cascade#

A key concept to successfully setting up CSS selectors is understanding what is known as CSS specificity, and the "C" in CSS, which is the cascade.

Specificity is a weight that is applied to a given CSS declaration, determined by the number of each selector type in the matching selector. When multiple declarations have equal specificity, the last declaration found in the CSS is applied to the element. Specificity only applies when the same element is targeted by multiple declarations. As per CSS rules, directly targeted elements will always take precedence over rules which an element inherits from its ancestor. - MDN docs

Proper use of the cascade and selector specificity means you should be able to entirely avoid the use of !important in your stylesheets.

Increasing specificity comes with the result of overriding inheritance from the cascade.

As a small example - what color will the .item be?

<div id="specific">
<span class="item">Item</span>
</div>
#specific .item {
color: red;
}

span.item {
color: green;
}

.item {
color: blue;
}

The .item will be red because the specificity of including the #id in the selector wins against the cascade and over the element selector.

This doesn't mean to go adding #ids to all your elements and selectors, but rather to be aware of their impact on specificity.

Key concept: The higher the specificity, the more difficult to override the rule.

Every project will be unique in its needs in terms of reusability of rules. The desire to share rules with low specificity has lead to the rise of CSS utility-driven frameworks such as Tailwind and Bulma.

On the other hand, the desire to tightly control inheritance and specificity such as within a design system makes naming conventions like BEM popular. In those systems, a parent selector is tightly coupled with child selectors to create reusable components that create their own specificity bubble.

If you're thinking "I don't need to learn these because I use a framework/design system" then you are greatly limiting yourself in terms of using CSS to its fullest extent.

The beauty of the language can be found in constructing elegant selectors that do just enough and enable tidy small stylesheets.

Universal Selector#

The universal selector - * - is so named because it applies to all elements universally.

There used to be recommendations against using it, particularly as a descendent, because of performance concerns, but that is no longer a valid consideration. In fact, it hasn't been a concern in over a decade. Instead, worry about your JS bundle size and ensuring your images are optimized rather than finessing CSS selectors for performance reasons ?

A better reason to only use it sparingly is that it has zero specificity when used by itself, meaning it can be overridden by a single id, class, or element selector.

Practical Applications For the Universal Selector#

CSS Box Model Reset

My most common usage is as my very first CSS reset rule:

*,
*::before,
*::after
{
box-sizing: border-box;
}

This means that we want all elements to include padding and borders in the box model calculation instead of adding those widths to any defined dimensions. For example, in the following rule, the .box will be 200px wide, not 200px + 20px from the padding:

.box {
width: 200px;
padding: 10px;
}

Vertical Rhythm

Another very useful application was recommended by Andy Bell and Heydon Pickering in their Every Layout site and book and is called "The Stack" which in it's most simple form looks like this:

* + * {
margin-top: 1.5rem;
}

When used with a reset or parent rule that reduces all element margins to zero, this applies a top margin to all elements that follow another element. This is a quick way to gain vertical rhythm.

If you do want to be a little more - well, selective - then I enjoy using it as a descendent in specific circumstances such as the following:

article * + h2 {
margin-top: 4rem;
}

This is similar to the stack idea, but more targeted towards the headline elements to provide a bit more breathing room between content sections.

Attribute Selector#

This is an exceedingly powerful category and yet often not used to its full potential.

Did you know you can achieve matching results similar to regex by leveraging CSS attribute selectors?

This is exceptionally useful for modifying BEM styled systems or other frameworks that use related class names but perhaps not a single common class name.

Let's see an example:

[class*="component_"]

This selector will select all elements which have a class that contains the string of "component_", meaning it will match "component_title" and "component_content".

And you can ensure the match is case insensitive by including i prior to closing the attribute selector:

[class*="component_" i]

But you don't have to specify an attribute value, you can simply check if it's present, such as:

a[class]

Which would select all a (link elements) that have any class value.

Review the MDN docs for all the possible ways to match values within attribute selectors.

Practical Applications For Attribute Selectors#

Assist in Accessibility Linting

These selectors can be wielded to perform some basic accessibility linting, such as the following:

img:not([alt]) {
outline: 2px solid red;
}

This will add an outline to all images that do not include an alt attribute.

Attach to Aria to Enforce Accessibility

Attribute selectors can also help enforce accessibility if they are used as the only selector, making the absence of the attribute prevent the associated styling. One way to do this is by attaching to required aria attributes

One example is when implementing an accordion interaction where you need to include the following button, whether the aria boolean is toggled via JavaScript:

<button aria-expanded="false">Toggle</button>

The related CSS could then use the aria-expanded as an attribute selector alongside the adjacent sibling combinator to style the related content open or closed:

button[aria-expanded="false"] + .content { 
/* hidden styles */
}
button[aria-expanded="true"] + .content {
/* visible styles */
}

Styling Non-Button Navigation Links

When dealing with navigation, you may have a mix of default links and links stylized as "buttons". In this case, it can be very useful to use the following to select non-"button" links:

nav a:not([class])

Remove Default List Styling

Another tip I've started incorporating from Andy Bell and his modern CSS reset is to remove list styling based on the presence of the role attribute:

/* Remove list styles on ul, ol elements with a list role, 
which suggests default styling will be removed */

ul[role='list'],
ol[role='list']
{
list-style: none;
}

Child Combinator#

The child combinator selector - > - is very effective at adding just a bit of specificity to reduce scope when applying styles to element descendants. It is the only selector that deals with levels of elements and can be compounded to select nested elements.

The child combinator scopes descendent styling from any descendent that matches the child selector to only direct descendants.

In other words, whereas article p selects all p within article, article > p selects only paragraphs that are directly within the article, not nested within other elements.

✅ Selected with article > p

<article>
<p>Hello world</p>
</article>

? Not selected with article > p

<article>
<blockquote>
<p>Hello world</p>
</blockquote>
</article>

Practical Applications For the Child Combinator#

Nested Navigation List Links

Consider a sidebar navigation list, such as for a documentation site, where there are nested levels of links. Semantically, this means an outer ul and also nested ul within li.

For visual hierarchy, you likely want to style the top-level links differently than the nested links. To target only the top-level links, you can use the following:

nav > ul > li > a {
font-weight: bold;
}

Here's a CodePen where you can experiment with what happens if you remove any of the child combinators in that selector.

Scoping Element Selectors

I enjoy using element selectors for the foundational things in my page layouts, such as header or footer. But you can get into trouble since those elements are valid children of certain other elements, such as footer within a blockquote or an article.

In this case, you may want to adjust from footer to body > footer.

Styling Embedded / Third-Party Content

Sometimes you truly do not have control over class names, IDs, or even markup. For example, for ads or other JavaScript-driven (non-iframe) content.

In this case, you may be faced with a sea of divs or spans, in which case the child combinator can be very useful for attaching styles to varying levels of content.

Note that many of the other selectors discussed can help in this scenario as well, but only the child combinator deals with levels and can affect nested elements.

General Sibling Combinator#

The general sibling combinator - ~ - selects the defined elements that are located somewhere after the preceding (prior defined) element and that are within the same parent.

For example, p ~ img would style all images that are located somewhere after a paragraph provided they share the same parent.

This means all the following images would be selected:

<article>
<p>Paragraph</p>
<h2>Headline 2</h2>
<img src="img.png" alt="Image" />
<h3>Headline 3</h3>
<img src="img.png" alt="Image" />
</article>

But not the image in this scenario:

<article>
<img src="img.png" alt="Image" />
<p>Paragraph</p>
</article>

It is likely you would want to be a bit more specific (see also: the adjacent sibling combinator), and this selector tends to be used most in creative coding exercises, such as my CommitSweeper pure CSS game.

Practical Applications For the General Sibling Combinator#

Visual Indication of A State Change

Combining the general sibling combinator with stateful pseudo class selectors, such as :checked, can produce interesting results.

Given the following HTML for a checkbox:

<input id="terms" type="checkbox">
<lab