Nowadays a lot of Operating Systems support Light and Dark themes and even Web browsers started supporting it thanks to the prefers-color-scheme media query.
In this blog post I’m going to explain you how to apply and switch color schemes (themes) in a web application. I find it important that the mechanism is simple and the codebase stays maintainable (DRY).
Table of contents
- Apply styling based on your OS color scheme using CSS
- Toggling color scheme manually instead of deriving from OS
- How to apply this in scale?
- More than one theme
- Optimizing the solution
- Conclusion
Apply styling based on your OS color scheme using CSS
Let’s quickly look into what CSS rules you need to apply to render a web site in dark mode if the OS setting is also in dark mode.
Just wrap your styling in a <span class="cm-def">@media</span> <span class="cm-operator">(</span><span class="cm-tag">prefers-color-scheme</span><span class="cm-operator">:</span> <span class="cm-tag">dark</span><span class="cm-operator">)</span>
block to apply the styling when the color scheme is set to ‘dark’. Try to change your OS setting to Dark to see the text render white on black instead of black on white.
The dark rules override the default rules. We do not wrap the default style in a ‘light’ media query because we need a fallback in case there is no browser support for this media query.
I’m using TailwindCSS a lot, and there is built-in dark: variant, so that you can even achieve this with only HTML and CSS classes.
(Note that Tailwind playground itself also has a theme toggle. Inception!)
Toggling color scheme manually instead of deriving from OS
Why Tailwind?
The main issue with writing maintainable CSS is that CSS is scoped globally and you structure the styles using so called specificity. When the code base grows, your CSS will become unmaintainable unless you apply a set of conventions yourself, like BEM or SMACCS.
Tailwind solves this issue by generating a lot of short-worded utility classes, all of them having specificity 1. You sprinkle these classes where you need them in your HTML code.
If you use Tailwind with a CSS processor, your bundle will only contain the classes you use, and not also the unused styles. (This is why usually Material and Bootstrap libraries are so huge.)
For every HTML/CSS-only code example, I have included both vanilla CSS and TailwindCSS sandboxes, so that you can compare yourself how Tailwind could make your workflow more productive by writing less code to achieve more.
Sometimes you want the user to toggle the color scheme. (Optionally you can set the intial color scheme to the OS scheme.)
There is user interaction involved, so now we have to use JavaScript. But let me first start with the CSS parts.
Instead of depending on a media query block that is hardcoded in your style sheet, like in the examples above, we have to depend on something that a user can change on a running website.
A very common way to do this is to toggle a class name on the <body>. If the class is dark
we can override styling based on a more specific .dark .block
selector.
Remove the dark
class in the following examples to see what I mean.
With TailwindCSS you can still use the dark:
variant, but to toggle using a container class instead of a media query you have to set darkMode: 'class'
in the configuration.
Next thing is how to toggle the class?
We need some mechanism in Javascript to toggle the class. For example using a dropdown select or a toggle button. To be honest you can implement this in any way using your favorite JavaScript framework. Or just use vanilla JS like I did in the following example. It isn’t very relevant (and not in scope for this blog post) how you do it, as long as the dark
class is added and removed on a container that should have themed styling.
How to apply this in scale?
The examples given were pretty small and simple. When an application grows and you have to style everything twice, your codebase becomes pretty cluttered. Even when using TailwindCSS, you have to apply all color related classes in pairs. You will see text-white dark:text-black
everywhere. Exceptions like text-white text-darkgray
will probably not occur, so applying the same tuples over and over is a lot of boilerplate and doesn’t increase the readability and maintainability of your code.
Also the bundle size will increase, which is a waste of resources when people do not use the color scheme switcher.
While the :dark
variant, like all Tailwind classes, is pretty direct and low-level, most designers create a dark color palette to replace all light colors with. So when a text is black in light mode it always will be white in dark mode. So what we want to do in practice is just swapping a set of colors with a different set of colors.
Bear with me, I will explain how to do this in the section, but first let me introduce a second ‘scaling’ problem.
More than one theme
Leveraging light and dark scheme support from your browser and operating system is pretty powerful and straighforward, but what if you want to support more than just dark and light? For example, red, green and blue?
As far as I know there are only light and dark color schemes in any OS and the media query reflects that.
The default ‘dark mode’ variant that is part of TailwindCSS only supports dark mode, not even a light mode, because they expect the light mode to be the default. It depends on the media query because TailwindCSS is just syntactic sugar.
It looks like your only option is a custom mechanism like the class-based color scheme we have just seen in the previous section. A class can have any name, not only dark
but also red
, green
or blue
.
If we would implement this the same as with dark and light classes, the result would be as follows:
The scheme specific CSS rules are not very DRY, so we are going to refactor this using CSS custom properties a.k.a. CSS variables.
Fair enough, in this example I did not save a lot of bytes, but if you look carefully you’ll see that the color palettes are defined once and we can reuse them whereever we want. This also means that the primary color can be applied in any CSS property such as border, background, drop-shadows, outline and gradient. And when you switch the theme, all var(--primary)
colors swap from red
to green
.
For completeness this is the same implementation but then with TailwindCSS. We aren’t using any Tailwind specific features like the dark variant anymore, so the only difference is that is it just less code to write and easier to read (if you are used to Tailwind classes).
Optimizing the solution
As said before your CSS output file or app bundle will become larger when styles for more than one theme are included.
A very common approach to this (generic) problem is bundle splitting and/or lazy loading. CSS and asset performance optimization is a very broad topic on its own, so I will demonstrate two pragmatic solutions depending on your situation.
Are you building a multi themed website? Or are you building multiple websites, each of them having a different theme?
In the first case, you need to switch themes on a deployed and running website. The applied theme depends on the OS color scheme setting or the selected theme inside the application.
In the second case, the websites may be hosted on different servers. They are unrelated and you should prepare CSS files per theme when you compile the application.
Run time style sheet switching
If you need to swap themes run time, first you have to move the CSS vars to one file per theme. Instead of one index.css, you will have index.css, palette-red.css, palette-blue.css and palette-green.css.
The palette files contain the CSS variables, wrapped in a :root
selector, so that these variables can be used on any DOM element. The index.css file contains the rest of the styles.
Then you also don’t need to select a theme with a class name but you swap CSS files instead.
Build time style sheet switching
If you are building 3 different websites in 3 different themes, you can build a CSS file 3 times. You don’t need CSS variables anymore (these are runtime variables!). Just put the colors directly in 3 separate tailwind.config.js files. To prevent a lot of duplicated config code, import and merge the base config into your themed Tailwind config files.
Then build the project 3 times, each time with another Tailwind config.
Since this solution involves a little bit of engineering, it is not possible to demonstrate this using a static CodePen or Tailwind Play example. Therefore I created a GitHub project that you can clone and run yourself.
Visit the multi-theming repo at GitHub.
Run npm run build:red
to build the website using the red theme to /dist/red. Run npm run build:blue
to /dist/blue.
You never guess what npm run build:green
does.
The example is using Vite, but any build tooling satisfies. Webpack, Vue CLI, React React App. As long as there is a PostCSS build step it will work, because TailwindCSS is a PostCSS plugin.
You can even compile the styles separately using only PostCSS (CLI). If you pass the tailwind config file name to the PostCSS config via an environment variable, it will work.
Conclusion
Whenever you want dark/light themes that are derived from the operating system, or switch between two or more arbitrary themes, CSS is your friend!
Using small live examples I showed you how to create a schema/theme based web site yourself.
Wrap styling overrides in the powerful prefers-color-scheme
media query to allow for schema specific styles or toggle between classes at top level (body) to switch to any theme programmatically. Creating a theme switcher requires some JS, but this is pretty straightforward using event handlers and some class or theme stylesheet toggling logic.
If you need multiple website each with their own theme, I showed you how to use the power of PostCSS and TailwindCSS to create a parameterized build.
There are more tools in the toolbox such as the TailwindCSS multi theme plugin to create variants that go beyond just dark:
. Or use the color-scheme
CSS property to force the browser user stylesheets into a specific scheme so that the native parts of the web page are already styled correctly. There are also framework specific custom solutions like Vue theme annotated style blocks or React ThemeProvider
from styled-components or Emotion.
These are too much details for this blog post to elaborate on, but I encourage you to try out these techniques as well!