Dec 17

Cascade Layers

Don't let specificity force you into strict selector conventions. Cascade Layers allow us to manage specificity without resorting to naming hacks or the `!important` flag.

By Miriam Suzanne

Specificity is only one part of the CSS Cascade, but it's the part we interact with the most often – and it can quickly become a labyrinth of twisted selectors, frustration, and swearing. That's a terrible way to start the holidays! Unless we’re talking about the Jim Henson movie, which is excellent…

But this year, something changed in CSS. Back in February/March of 2022, all the modern web browsers released Cascade Layers – a new feature that gives you (and me) control over how our selectors interact, regardless of their specificity or code order:

/* establish layer-order: last takes precedence */
@layer default, theme, state;

@layer default {
button {
background: rebeccapurple;
color: white;
}
}

@layer state {
:disabled {
background: dimgray;
}
}

@layer theme {
button.danger {
background: maroon;
}

button.info {
background: darkslateblue;
}

#call-to-action {
background: mediumvioletred;
}
}

Since the state layer has the highest layer priority and layers override specificity, the disabled styling will always win in a conflict – even though the theme styles have a higher specificity and come later in the code. We can also nest layers:

@layer reset, framework, components, utilities;

@layer components {
@layer default, theme, state;

@layer state {
/* the nested components.state layer */
:disabled { background: dimgray; }
}
}

@layer components.state {
/* also the nested components.state layer */
:focus-visible { outline: thin dashed hotpink; }
}

And import entire stylesheets into a layer:

@import url(reset.css) layer(reset);
@import url(bootstrap.css) layer(bootstrap.external);

@layer bootstrap.local {
/* anything here will override the imported code in bootstrap.external */
}

This can be useful on any size project for both overall architecture and individual components: 

Layer Ordering

Layers are stacked in the order that each layer-name first appears in the codebase, with later layer-names taking precedence over earlier layers. That means we can allow them to stack implicitly:

@layer low { /* the lowest layer */ }
@layer medium { /* a middle layer */ }
@layer high { /* the highest layer */ }

Or we can define the layer order explicitly by introducing layer names in order with a single list. This is recommended because it ensures a "single source of truth" for the intended order of layers:

@layer low, medium, high;

But there’s no magic to the list syntax – it simply introduces layer names to the stack. Since the stack is based on the first appearance of a name, repeating layer names won’t change their order. This will result in exactly the same layer order as above:

/* introduce the low and medium layers */
@layer low, medium;

@layer low { /* ... */ }
@layer medium { /* ... */ }
@layer low { /* ... */ }
@layer high { /* introduce the highest layer! */ }

/* all these layers already exist, so nothing changes... */
@layer high, medium, low;

We can think of this behavior as though the layers with a shared name are sorted together and merged, so the result is still:

@layer low { /* all the code from ‘low’ layer rules */ }
@layer medium { /* all the code from ‘medium’ layer rules */ }
@layer high { /* all the code from ‘high’ layer rules */ }

That sorting happens independently at each layer of nesting:

/* both medium & medium.low are introduced */
@layer low, medium.low;

@layer high { /* … */ }
@layer medium.high { /* … */ }

First, the top-level layers are sorted (low, medium, high), and then the nested layers are sorted inside of those:

@layer low;
@layer medium.low;
@layer medium.high;
@layer high;

At each level, styles that are un-nested have the highest priority - no matter where they appear in the code. In this example, all the links will be green, since that selector is layered the least:

/* styles not nested in a layer take priority */
a { color: green; }

@layer one {
/* the less nesting, the the more powerful */
a { color: brown; }

@layer two {
a { color: red; }
}
}

Layers don’t add priority the same way that !important does, but instead, they remove priority in relation to unlayered styles. Rather than creating an arms race to the top, they help us de-prioritize code that we should be able to override easily from somewhere else. But it’s also more nuanced than the :where() selector that simply removes all specificity information from the selectors inside it. We can have both!

Layers help us reclaim declarative control of the intent behind our CSS without removing helpful information about the specificity of selectors. We’re managing the cascade instead of removing it.

By defining a full list of layers up-front, we not only ensure a single source of truth – but it also allows us to load the rest of our layered styles in any order we want. That can be especially useful when using automated tools, like JS frameworks that only load the styles for components that are used on a given page. If all those styles are layered, and all the layer ordering is defined in advance, it doesn’t matter what order the component styles are loaded.

There’s a lot more we could get into here – like anonymous layers and the interaction between layers and importance. I recommend checking out some other articles that get into more detail:

Getting Started

Layers already have very broad browser support, so many of us can start using them right away. There’s also a polyfill written in postCSS, for adding support to older browsers without any performance issues.

To start integrating layers onto an existing project, I work from the bottom up. Since layers deprioritize styles, I start with the styles that should have the lowest priority: resets, normalization, and global defaults. Depending on the project, that might be a single layer or multiple, but the idea is the same.

First, we add a layer list that is easy to find at the top of our styles. It doesn’t need to be a full list, just the layers we know we’re going to need. We can even put that directly in the HTML if that helps ensure it always comes first:

<style>/* keep this before linked styles */
@layer reset, default;
</style>
<link rel="stylesheet" href="path/to/styles.css">

If we want to take that a step further, we can also use @import to load our existing styles into a layer of their own: 

<style>
@layer reset, default, legacy;
@import url(path/to/new-styles.css); /* put our layered styles in here */
@import url(path/to/old-styles.css) layer(legacy);
</style>

It’s great to get all our styles layered if we can because once we have un-layered styles, there’s nowhere higher to go. By making sure everything is in a layer, we have total flexibility to add new styles above or below what’s already there.

Once the low-priority styles are layered, I would focus on any external styles that a site relies on: design systems, component libraries, and third-party frameworks. Those are the most likely source of specificity frustration, and moving them into layers will make them much easier to manage.

I use Harry Roberts’ Inverted Triangle as a guide. Where he recommends allowing specificity to grow slowly with each implied layer of a project:

A line graph where the y-axis is specificity and the x-axis is lines of code, and vertical cross sections represent groups of styles where generic has lowest specificity and lines of code and the line trends upwards through elements, objects, components, and utilities where both axes are highest.

I break off one layer at a time, from lowest to highest, making it an explicit layer in the cascade: 

The same line graph but now the first part of the line - "generic" - where specificity and lines of code are lowest has a break from the rest of the upward-trending line.

As each layer is given an explicit place in the cascade, we have more freedom to use a range of specificity within that layer. We no longer need to constrain ourselves to single-class BEM selectors or carefully remove specificity everywhere possible! Each layer is self-contained, and we can rely on specificity to help work things out inside each layer:

Now the line graph is segemented within each veritcal section and shows a more gradual increase between each section for specificity and lines of code, demonstrating how each layer is now self-contained.

Unleash the Selectors!

Selectors are one of the most powerful (and under-utilized) features in CSS – allowing us to target elements for styling based on a whole range of details. We can use them to represent state, enforce accessibility patterns, manage content flow and element spacing, incorporate randomness, establish component parameters, and so much more. Most importantly, selectors allow us to define abstracted style patterns that can be written in one place and reused across components.

CSS is designed to be systemic and declarative – giving the browser information about why different styles might be applied in different situations. While we can apply the same styles to .contact–submit__focus or .contact [type='submit']:focus-visible, the former is a meaningless string of characters that we have to define and apply ourselves (often with JS), while the latter conveys detailed information that the browser can use to apply styles on our behalf. The result is more resilient, performant, and readable code.

Selectors are there for a reason, and we should take advantage of their full potential. Instead, we’ve been restricting what selectors are allowed and enforcing more and more limited conventions in an attempt to somehow avoid the cascade. But the cascade is unavoidable; it’s the core of the language. There will always be selector conflicts, and there will always be an algorithm to resolve those conflicts. All we’ve been doing is denying ourselves access to one of the coolest features on the web platform. 

I hope that cascade layers can help to turn that trend around.

Miriam selected Colorado Freedom Fund for an honorary donation of $50 which has been matched by Netlify

Colorado Freedom Fund

While people with money can pay their way out of jail, poor people don’t have that luxury. CFF is an abolitionist-led non-profit, and a member of the National Bail Fund Network – paying cash bail for our neighbors trapped in Colorado cages.