As I wrote, creating a new theme using custom properties is not a big deal; you overwrite your colors individually. If you build your system with this in mind from the start, you won't have a problem.
You have a theme like this:
:root {
--color-primary: hsl(262deg 71% 49%);
--color-heading: hsl(205deg 100% 2%);
--color-text: hsl(208deg 9% 42%);
}
And a dark theme like this:
:root[data-theme-mode="dark"] {
--color-primary: hsl(261deg 54% 70%);
--color-heading: hsl(206deg 100% 7%);
--color-text: hsl(0deg 0% 97%);
}
We add the data-theme-mode="dark"
to the root element, which is the html
tag. We want to update this attribute based on our theme switcher, previously saved value, or system preference to get the right colors.
As the previous section shows, we used a data attribute to identify the dark theme. Only the dark theme; we handle the light one as a default.
You can identify and handle the theme as you wish; data attribute is just one convenient solution.
On the first visit, we want the system settings that will complicate our code.
This example has three states: system
, dark
, and light
. In practice, it will be reduced to two: light
and dark
. We handle the light as a default (we write our code as always) and dark as an overwriting to redeclare the color variables.
The UI of this is totally on you. I usually use a select at the footer, but many great patterns are available out there. The things you need to pay attention to are:
You need a small JS script that detects which option the user chooses and updates the option at the html
tag (and the state of the theme switcher).
You may want to store the selected theme in the user's system but where?
You have two options:
Both options are valid and usable for us. Which one you choose will depend on your stack. A cookie is a better option if you use PHP and your cache doesn't interfere with it (doesn't cache it). In this case, you can do the hard lifting on the server side.
The localStorage is easier to handle from the front-end side because it has a capable API. It also doesn't travel to the server, only live on the client system. We need it because we don't use a back-end, we have a static site, or our server cache kills the cookies.
Knowing all of this, here is our theme switcher JS code that you can also find in the Spruce UI with the markup and styling.
(() => {
const themeSwitcher = document.querySelector('#theme-switcher');
const preferredTheme = localStorage.getItem('preferred-theme') ?? 'system';
themeSwitcher.addEventListener('input', (e) => {
const theme = e.target.value;
const systemMode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
localStorage.setItem('preferred-theme', theme);
document.documentElement.setAttribute('data-theme-mode', theme === 'system' ? systemMode : theme);
themeSwitcher.setAttribute('data-theme-mode', theme);
themeSwitcher.querySelector(`option[value="${theme}"]`).selected = 'selected';
});
themeSwitcher.setAttribute('data-theme-mode', preferredTheme);
themeSwitcher.querySelector(`option[value="${preferredTheme}"]`).selected = 'selected';
})();
As you see, everything is simple here.
Once you can switch between themes, you must detect them—both the previous user and system settings.
The tricky part here is the user's system preference. We want to query the preferred color scheme if the theme switcher is in system mode (on purpose) or in the default "empty" state.
The prefers-color-scheme is a CSS media feature that we can use to detect the system preferences with the matchMedia method.
We can also watch for changes in this setting to reflect the system change in real-time (no need for browser refresh).
(() => {
const systemMode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const preferredTheme = localStorage.getItem('preferred-theme');
function setTheme(theme) {
document.documentElement.setAttribute('data-theme-mode', theme === 'system' ? systemMode : theme);
}
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (localStorage.getItem('preferred-theme') === 'system' || localStorage.getItem('preferred-theme') === null) {
setTheme(e.matches ? 'dark' : 'light');
}
});
setTheme(preferredTheme || systemMode);
})();
The last step that needs to be added is handling the media files. There are cases where we want to display separate images for each theme. This can happen with any cover image or thumbnail, but one place where it will almost always be the case is your site's logo.
We can use a data attribute for each related asset (light and dark) and switch them on load (or on the back-end based on cookie value) based on the preferred setting. If you change them on the front-end with JS, you can also use them for lazyloading while initially serving a smaller placeholder.
In the code below, we use the MutationObserver to observe the html element for any change and call the changeAssets function.
(() => {
const htmlElement = document.querySelector('html');
const systemMode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
function changeAssets(theme) {
if (!theme) return;
const themeAssets = document.querySelectorAll('img[data-theme-mode]');
themeAssets.forEach((el) => {
el.src = el.getAttribute(`data-${theme}-asset`);
});
}
changeAssets(htmlElement.getAttribute('data-theme-mode') === 'system' ? systemMode : htmlElement.getAttribute('data-theme-mode'));
const observer = new MutationObserver(() => {
changeAssets(htmlElement.getAttribute('data-theme-mode') === 'system' ? systemMode : htmlElement.getAttribute('data-theme-mode'));
});
observer.observe(htmlElement, { attributes: true });
})();
You can use this code with the following HTML structure.
<img
src="logo-dark.svg"
alt="Spruce CSS"
data-theme-mode
data-light-asset="logo-dark.svg"
data-dark-asset="logo-light.svg"
>
Making a theme switcher, we store cookies (or cookie-like data) on the user's system. As far as I know, there isn't any precedent court ruling related to whether we can (or not) store this data on the user's computer without consent, but in the future, it can happen.
If we have to ask for permission beforehand, we have to add a new acceptance layer to the theme switcher (this is the point where we write any local storage).
You can see this in action in our Spruce Docs theme with a slightly modified theme switcher (it still has 95% the same code but using buttons not a select). Because of Spruce CSS, the theme handling is a bit more abstract, but if you compile the code, you will see the same CSS custom property structure we showed you here.
Right now, for us, this is quite a good solution. Because it does the work on the client side, the asset handling is not our favorite because there can be some unnecessary flashing. This is primarily noticeable with the logo, but we try to tackle this with an embedded SVG with its own CSS for recoloring.