Using native CSS Cascade layers with TailwindCSS

Emmanuel Amoah
6 min readSep 7, 2023

--

Photo by Nick Decorte on Unsplash

A good understanding of the CSS Cascade will incredibly improve the way you deal with CSS, as it is a core aspect of the language. In this article, I’m going to show you how you can leverage the concept of Cascade layers, especially in tandem with TailwindCSS to streamline your workflow. This article will assume prior knowledge of Cascade layers and TailwindCSS, so if you’re not familiar with it, you can learn about Cascade layers on the MDN docs.

In a typical Tailwind project, you will mostly find something like this at the beginning of the main CSS entry file:

@tailwind base;
@tailwind components;
@tailwind utilities;

These directives tell tailwind where to include its generated rules in the final output CSS.

Tailwind’s layers

Tailwind uses ‘layers’ to organise its CSS. However, there is a difference between Tailwind’s own layers, and CSS’ native Cascade layers; Tailwind’s layers are artificial. In other words, its layers only pertain to the preprocessing stage. Tailwind is a PostCSS plugin, and after its transformation is done, the final output, as far as native CSS is concerned, is all contained in a single layer.

To demonstrate this, let’s look at the following code using the snippet above and adding some rules using the @layer directive with Tailwind’s layers:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
.btn {
background-color: blue;
}
}

@layer base {
body {
overflow-x: hidden;
}
}

Here’s what the final output would look like (simplified for brevity):

body {
overflow-hidden;
}
...
.btn {
background-color: blue;
}

As you can see, although the body rule was declared after that of .btn in the source file, it now appears before .btn in the output.

This happens because the @tailwind base; directive appears before @tailwind components; in the source. Hence, all rules declared within @layer base {...} blocks, including plugin-generated rules which target the base layer, will be relocated to appear before those of components and utilities. This behaviour, along with other benefits its layers provide, is explained in Tailwind’s docs on layers.

Employing native CSS layers

If no CSS layers are declared in your project, then all rules are collected into a single, implicit anonymous layer, which is an actual layer, equivalent to declaring something like:

/* An explicit anonymous layer */
@layer {
/* All your page's CSS, in order of appearance */
}

But this wouldn’t benefit much, as all your CSS will be subject to specificity issues, as we will see in a moment.

There are different ways to declare layers in CSS. But we’ll be using the @import rule, which is simplest for our use case.

First, we need to modify the three @tailwind directives, to use @import statements instead. Tailwind provides entry files which correspond to the different layers it uses, so we can import these files directly from node_modules as follows:

@import "tailwindcss/base";         /* contains @tailwind base;       */
@import "tailwindcss/components"; /* contains @tailwind components; */
@import "tailwindcss/utilities"; /* contains @tailwind utilities; */

This effectively does the same thing, however, using @import allows us to include custom layers in a strategic order, since we cannot intersperse @tailwind directives with @import statements. You can find an example of this in Tailwind’s article on build-time imports. But most importantly, this syntax is what will allow us declare our layers concisely.

Tailwind reserves the names of those three layers, so we shouldn’t use them in declaring our own layers, to avoid conflict. Specifically, a layer block declared using one of those names (e.g., @layer base {...}) will be processed by tailwind and transformed as we saw previously, while a block using a different name (e.g., @layer my-layer {...}) will be left untouched, and end up in the final CSS.

Now we’re ready to create our own layers. Since we cannot use the same names as Tailwind, we’ll just add a prefix. I’ll go with my-, for demonstration purposes. Others might decide to go with tw-, but how I see it is that my website comprises Tailwind, and so it is my layers that Tailwind styles would end up in, and not vice-versa. But the choice is yours.

So we’ll declare our layers as follows:

@import "tailwindcss/base" layer(my-base);
@import "tailwindcss/components" layer(my-components);
@import "tailwindcss/utilities" layer(my-utilities);

This directly creates three new layers, and imports the corresponding styles right into them. If your Tailwind version is earlier than v3.2.5, you may encounter a warning about @tailwind nested rules. You’d need to update your Tailwind version to get rid of that warning.

The my-utilities layer, in my opinion, can be deemed optional, since it is the last layer anyway, and we would like Tailwind’s utility classes to override everything else, keeping consistent with its “utility-first” idiom.

From this point, any of Tailwind’s layer-specific rules will end up in our own corresponding native layer. For example, even if we still used Tailwind’s own layers, as in @layer components {...}, styles in that block will end up in the output as being inside of @layer my-components {...}.

If you have custom layer styles imported into your main CSS in-between Tailwind’s layers, be sure to add the respective layer() declaration to the import statements, i.e.:

@import "tailwindcss/base" layer(my-base);
@import "./custom-base-styles.css" layer(my-base);

@import "tailwindcss/components" layer(my-components);
@import "./custom-components.css" layer(my-components);

@import "tailwindcss/utilities" layer(my-utilities);
@import "./custom-utilities.css" layer(my-utilities);

This ensures all base styles remain in the my-base layer, and so on.

How this could be useful

Up unto this point, you might not notice any specific advantage of using native layers, but there’s a case, among many others, where this will prove to be of much benefit—in component frameworks like Vue or Svelte.

In Single File Components, you are restricted from using Tailwind’s layers. As the docs explain, this is because each component is processed in isolation, and so the CSS defined in its <style> tags is unaware of the @tailwind directives present in the main CSS file. Hence, you’d get an error saying “…no matching `@tailwind <layer>` directive is present.”

So if you want to write some CSS for your custom component, which you’d like to be overridden by utility classes, you’d have to write them in the main CSS file, or in a file imported by it in order to use the @layer directive. But that breaks encapsulation, doesn’t it?

The good news is, we can now take advantage of the native layers we defined to preserve encapsulation, while keeping the cascade in the correct order. So in this Vue component for example, we can have something like:

<!-- Button.vue -->

<template>
<button type="button" class="btn">
<slot />
</button>
</template>

<style>
@layer my-components {
.btn {
border-radius: 4px;
}
}
</style>

We can’t use components here, but we can use my-components! This way, no matter where this component’s styles end up, we can be sure that it is logically where it needs to be, and our utility classes can override the component styles as expected. For example, the above component could be used like so: <Button class="rounded-lg" /> and the .rounded-lg utility class will take precedence. Without the layer declaration, the component’s styles will end up non-layered, effectively overriding all our layered styles, including the utility classes, due to how the cascade works.

Another significant advantage to this is that you wouldn’t have to worry about specificity in your component styles, because since they’re declared in the my-components layer, which comes before the utility classes, the utility classes will always take precedence, no matter how specific the component’s rules are, because in the Cascade algorithm, Origin is considered before Specificity.

Note: In order for the layers to work as expected, you must make sure the main CSS file—containing the @tailwind directives—is imported first in your project, before any other styles, or, with JavaScript frameworks, before the root component. This ensures that the native layers are created in the order we want, and don’t end up in the wrong order (for instance, component styles compiled and inserted before the main CSS).

Conclusion

Cascade Layers are extremely powerful tools for managing styles in a web project, and when combined with Tailwind’s own layer philosophy, give us enormous power and flexibility to control how our CSS is applied.
The @layer rule has wide browser support, so you can feel free to use it in your next project!

Got any thoughts or contributions? I’d love to hear them in the comments. Thanks for reading.

Social accounts:

--

--

Emmanuel Amoah
Emmanuel Amoah

No responses yet