title: Color Theme Switcher url: https://mxb.dev/blog/color-theme-switcher/ hash_url: 7a485d9382d6b3138c70cb2b9518fcb1
Last year, the design gods decided that dark modes were the new hotness. "Light colors are for suckers", they laughed, drinking matcha tea on their fixie bikes or whatever.
And so every operating system, app and even some websites (mine included) suddenly had to come up with a dark mode. Fortunately though, this coincided nicely with widespread support for CSS custom properties and the introduction of a new prefers-color-scheme
media query.
There’s lots of tutorials on how to build dark modes already, but why limit yourself to light and dark? Only a Sith deals in absolutes.
That’s why I decided to build a new feature on my site:
dynamic color themes! Yes, instead of two color schemes, I now have ten! That’s eight better than the previous website!
Go ahead and try it, hit that paintroller-button in the header.
I’ll wait.
If you’re reading this somewhere else, the effect would look something like this:
Nice, right? Let’s look at how to do that!
First up, we need some data. We need to define our themes in a central location, so they’re easy to access and edit. My site uses Eleventy, which lets me create a simple JSON file for that purpose:
[
{
"id": "bowser",
"name": "Bowser's Castle",
"colors": {
"primary": "#7f5af0",
"secondary": "#2cb67d",
"text": "#fffffe",
"border": "#383a61",
"background": "#16161a",
"primaryOffset": "#e068fd",
"textOffset": "#94a1b2",
"backgroundOffset": "#29293e"
}
},
{...}
]
Our color schemes are objects in an array, which is now available during build. Each theme gets a name
, id
and a couple of color definitions. The parts of a color scheme depend on your specific design; In my case, I assigned each theme eight properties.
It's a good idea to give these properties logical names instead of visual ones like "light" or "muted", as colors vary from theme to theme. I've also found it helpful to define a couple of "offset" colors - these are used to adjust another color on interactions like hover and such.
In addition to the “default” and “dark” themes I already had before, I created eight more themes this way. I used a couple of different sources for inspiration; the ones I liked best are Adobe Color and happyhues.
All my themes are named after Mario Kart 64 race tracks by the way, because why not.
To actually use our colors in CSS, we need them in a different format. Let’s create a stylesheet and make custom properties out of them. Using Eleventy’s template rendering, we can do that by generating a theme.css
file from the data, looping over all themes. We’ll use a macro to output the color definitions for each.
I wrote this in Nunjucks, the templating engine of my choice - but you can do it in any other language as well.
---
permalink: '/assets/css/theme.css'
excludeFromSitemap: true
---
{% macro colorscheme(colors) %}
--color-bg: {{ colors.background }};
--color-bg-offset: {{ colors.backgroundOffset }};
--color-text: {{ colors.text }};
--color-text-offset: {{ colors.textOffset }};
--color-border: {{ colors.border }};
--color-primary: {{ colors.primary }};
--color-primary-offset: {{ colors.primaryOffset }};
--color-secondary: {{ colors.secondary }};
{% endmacro %}
{%- set default = themes|getTheme('default') -%}
{%- set dark = themes|getTheme('dark') -%}
:root {
{{ colorscheme(default.colors) }}
}
@media(prefers-color-scheme: dark) {
:root {
{{ colorscheme(dark.colors) }}
}
}
{% for theme in themes %}
html[data-theme='{{ theme.id }}'] {
{{ colorscheme(theme.colors) }}
}
{% endfor %}
Now for the tedious part - we need to go through all of the site’s styles and replace every color definition with the corresponding custom property. This is different for every site - but your code might look like this if it’s written in SCSS:
body {
font-family: sans-serif;
line-height: $line-height;
color: $gray-dark;
}
Replace the static SCSS variable with the theme’s custom property:
body {
font-family: sans-serif;
line-height: $line-height;
color: var(--color-text);
}
Attention: Custom Properties are supported in all modern browsers, but if you need to support IE11 or Opera Mini, be sure to provide a fallback.
It’s fine to mix static preprocessor variables and custom properties by the way - they do different things. Our line height is not going to change dynamically.
Now do this for every instance of color
, background
, border
, fill
… you get the idea. Told you it was gonna be tedious.
If you made it this far, congratulations! Your website is now themeable (in theory). We still need a way for people to switch themes without manually editing the markup though, that’s not very user-friendly. We need some sort of UI component for this - a theme switcher.
The switcher structure is pretty straightforward: it’s essentially a list of buttons, one for each theme. When a button is pressed, we’ll switch colors. Let’s give the user an idea what to expect by showing the theme colors as little swatches on the button:
Here’s the template to generate that markup. We’ll use inline style attributes here to display the background, text and accent colors. The button also holds its id
in a data-theme-id
attribute, we will pick that up with Javascript later.
<ul class="themeswitcher">
{% for theme in themes %}
<li class="themeswitcher__item">
<button class="themeswitcher__btn" data-theme-id="{{ theme.id }}" style="background-color: {{ theme.colors.background }}">
<span class="themeswitcher__name" style="color: {{ theme.colors.text }}">{{ theme.name }}</span>
<span class="themeswitcher__palette">
<span class="themeswitcher__hue" style="background-color: {{ theme.colors.primary }}">{{ theme.colors.primary }}</span>
<span class="themeswitcher__hue" style="background-color: {{ theme.colors.secondary }}">{{ theme.colors.secondary }}</span>
<span class="themeswitcher__hue" style="background-color: {{ theme.colors.border }}">{{ theme.colors.border }}</span>
<span class="themeswitcher__hue" style="background-color: {{ theme.colors.text }}">{{ theme.colors.text }}</span>
<span class="themeswitcher__hue" style="background-color: {{ theme.colors.textOffset }}">{{ theme.colors.textOffset }}</span>
</span>
</button>
</li>
{% endfor %}
</ul>
There’s some styling involved as well, but I’ll leave that out for brevity here. If you’re interested in the extended version, you can find all the code in my site’s github repo.
The last missing piece is some Javascript to handle the switcher functionality.
class ThemeSwitcher {
constructor() {
this.activeTheme = 'default'
this.hasLocalStorage = typeof Storage !== 'undefined'
this.themeSelectBtns = document.querySelectorAll('button[data-theme-id]')
Array.from(this.themeSelectBtns).forEach((btn) => {
const id = btn.dataset.themeId
btn.addEventListener('click', () => this.setTheme(id))
})
}
}
if (window.CSS && CSS.supports('color', 'var(--fake-var)')) {
new ThemeSwitcher()
}
When somebody switches themes, we’ll take the theme id and set is as the data-theme
attribute on the document. That will trigger the corresponding selector in our theme.css
file, and the chosen color scheme will be applied.
Since we want the theme to persist even when the user reloads the page or navigates away, we’ll save the selected id in localStorage
.
setTheme(id) {
this.activeTheme = id
document.documentElement.setAttribute('data-theme', id)
if (this.hasLocalStorage) {
localStorage.setItem("theme", id)
}
}
On a server-rendered site, we could store that piece of data in a cookie instead and apply the theme id to the html element before serving the page. Since we’re dealing with a static site here though, there is no server-side processing - so we have to do a small workaround.
We’ll retrieve the theme from localStorage
in a tiny additional script in the head, right after the stylesheet is loaded. Contrary to the rest of the Javascript, we want this to execute as early as possible to avoid a FODT (“flash of default theme”).
OK that’s not actually a real term. I made that up.
<head>
<link rel="stylesheet" href="/assets/css/main.css">
<script>
localStorage.getItem('theme') &&
document.documentElement.setAttribute('data-theme', localStorage.getItem('theme'))
</script>
</head>
If no stored theme is found, the site uses the default color scheme (either light or dark, depending on the users system preference).
You can create any number of themes this way, and they’re not limited to flat colors either - with some extra effort you can have patterns, gradients or even GIFs in your design. Although just because you can doesn’t always mean you should, as is evidenced by my site’s new Rainbow Road theme.
Please don’t use that one.