Blog article

I’ve been using Tailwind for over three years now, and I absolutely love it. Recently, I took the time to understand how some of its magic works in an effort to take the convenience it provides to the next level. After I learned how to write my own plugins, I must say I like it even more.

First, let’s get the elephant out of the room. Like all utility-first CSS frameworks, Tailwind is quite verbose. The framework’s author has a great article that covers why that’s not a big deal. If you dislike utility-first CSS, it’s really a must-read for it might even change your mind.

From now on I’ll assume you’ve used Tailwind before. This article is not meant as an introduction to the framework or as an opinion piece about why it’s great but about how to extend it.

We used to live in harmony with long sequences of classes. Then, everything changed when dark mode attacked.

The problem

First, let’s imagine a single-mode scenario (light mode only).

In our tailwind.config.js file, we define our colors as usual:

module.exports = {
  theme: {
    colors: {
      primary: '#d91f26',
      surface: '#f0f0f0',
      'surface-1': '#f0e6e6',
      'surface-2': '#f0dcdc',
    }
  }
};

For this example, I chose primary and surface as the color names. I also added surface-1 and surface-2, slightly darker shades of the surface color. The names are not particularly good nor important in this example. How to name your colors is a whole other topic and out of scope for this article.

The one thing to note is that I’m not using names that refer to specific colors such as “white” or “red”, because we’ll want to have a single name for the light and dark values of a certain color, and colors such as “white” will most likely be closer to black when we switch to dark mode. That would be pretty awkward.

Let’s style a button

Here’s a simple button (I gave it a shadow, rounded the corners and bolded the font so the example looks nicer).

Light-mode button

The markup looks like this:

<button class="bg-surface text-primary hover:bg-surface-1 active:bg-surface-2 transition-colors px-4 py-2 shadow rounded-md font-bold">
  A button
</button>

It’s a long class list, but we’re used to that.

Now, a mode-aware button

Since we want to support dark mode, we’ll have two different colors for each of our original ones in our configuration file. Two different palettes, one for each of the modes.

tailwind.config.js

module.exports = {
  theme: {
    colors: {
      primary: {
        light: '#d91f26',
        dark: '#ff8a80'
      }
      ...
    }
  }
};

TailwindCSS has a built-in dark variant, so this should be easy, right? Yes, it is! However, it’s also not ideal.

Light and dark buttons

<button class="bg-surface-light dark:bg-surface-dark text-primary-light dark:text-primary-dark hover:bg-surface-1-light dark:hover:bg-surface-1-dark active:bg-surface-2-light dark:active:bg-surface-2-dark transition-colors px-4 py-2 shadow rounded-md font-bold">
  A button
</button>

As you can see, that’s a significantly longer list of classes. Nevertheless, my main gripe with it is not length per se. The real culprit is repetition:

  1. Within the dark mode classes themselves, dark is repeated. For example dark:bg-surface-dark.

  2. Within the light and dark versions of a class, the name of the base class is repeated, such as bg-surface-light and dark:bg-surface-dark.

  3. The hover and active states are distracting and long (bg-surface-light hover:bg-surface-1-light active:bg-surface-2-light). This is s not a consequence of dark mode, but it adds to the problem since those classes get duplicated too. Wouldn’t it be nice to have a more abstract way to indicate that an element is supposed to be hoverable/clickable and change colors when it’s interacted with?

I’ll tackle the first two today, since this post is first and foremost about dark mode. Number three is just a teaser for a future article.

How I solved it

After diving into the Tailwind Docs, I found something promising: It’s possible to define colors as CSS variables. That could solve our issue, since we can set the values for those variables depending on the current color scheme. Let’s try that approach and see where we get to.

A hopefully unnecessary disclaimer: None of this will work on Internet Explorer because it doesn’t support CSS Variables. If you need to support legacy systems, steer clear of this solution, and may the force be with you.

app.css

html {
  --color-primary: 218 31 38;
}
/* I'm assuming you add .dark to the body of your page when in dark mode,
read on if that's not your case :) */
.dark {
  --color-primary: 255 138 128;
}

tailwind.config.js

module.exports = {
  theme: {
    colors: {
      primary: 'rgb(var(--color-primary) / <alpha-value>)'
    }
  }
};

It works, so it amounts to some progress. It’s not a great final solution though, as it has many issues:

  • We need to keep our color names synchronized between two separate files, with two occurences in the CSS file and one in the Tailwind config file.

  • As we add colors, we’ll have to copy-paste the cryptic rgb... var... <alpha-value> boilerplate code over and over.

  • We need to define your colors as space-separated RGB values (well, we could use HSL if we wanted to). That’s a pretty rigid syntax that may not suit our development flow.

  • We’re using Tailwind, writing CSS for this just feels wrong.

It all boils down to the fact that it’s the Tailwind configuration file who should be responsible for setting our CSS variables. But hey, Tailwind plugins can do that! The plugin syntax for Tailwind includes an addBase function that can write CSS.

Here’s a version that takes advantage of that:

tailwind.config.js

module.exports = {
  theme: {
    colors: {
      primary: 'rgb(var(--color-primary) / <alpha-value>)'
    }
  },
  plugins: [
    plugin(({ addBase }) => addBase({
      html: {
        '--color-primary': '218 31 38'
      },
      '.dark' {
        '--color-primary': '255 138 128'
      }
    })
  ]
};

It’s better, but we still have to keep naming consistent in tailwind.config.js. The color name, primary, still appears three times. But since it’s all in one place now, it’s pretty straightforward to write a function that given a base config and a list of colors, generates the new colors and addBase call.

You don’t have to, though, as I already did it for you.

How you can solve it

I wrote the function I mentioned above, polished a few more details, and published the plugin as an npm package. You can simply run npm install --save-dev tailwind-mode-aware-colors, use it in your tailwind.config.js, and you’ll get fully abstracted mode-aware colors.

The plugin reads your colors directly from the config and converts them to the space-separated rgb format, so you use it like this:

module.exports = require('tailwind-mode-aware-colors')({
  theme: {
    colors: {
      primary: {
        light: '#d91f26',
        dark: '#ff8a80'
      }
    }
  }
};

It also reads the darkMode strategy from the Tailwind config so it’ll work with @media prefers-color-scheme: dark (or a custom selector) just as Tailwind itself does.

Final thoughts

This is just one option to tackle dark mode with Tailwind. Having separate palettes for each theme is not the only way to think about themed websites, but to me it’s a pretty natural way to do so. The viability of using this plugin on your project will depend largely on how you (and/or the project’s designer) work. However, if you have a design system that’s compatible with this, it can shave a lot of classes from your markup.

Another side benefit that I found when I used this approach (not the plugin, because that was not done yet) at a large-scale project is that if all the colors in the palette are mode-aware, you don’t get dark mode bugs like unreadable black text over a black background.

Here’s the repo for the package with detailed usage instructions (and the source code if you’re curious, it’s just 65 lines plus tests).

If you find any problems or want to request a feature, just open an issue!