Automatic dark mode
What we need to accomplish#
We have a site with both a light and dark mode for several themes and we need to implement how our site can easily switch between modes without a lot of headache.
Dark and light are “modes” not “themes”. See my rant about modes and themes for more info on why.
So I think we should call this feature “mode-switching”, and let‘s talk about the most straightforward way to implement it. We‘ll need to make use of some relatively modern CSS but the result will be worth it.
Let‘s start with the assumption that we already have some design tokens for colors—shadows might apply in dark mode also, but mostly colors are what change across modes. And you already have some mechanism that enables switching between modes. That mechanism can just be that your tokens obey the browser setting only, or you can also use some css selector and mode switcher button in your application that applies the mode regardless of the state of the browser setting.
This site has a mode switch button and a separate theme selector in the top right corner because modes and themes are not the same. :)
Either way, what we‘re trying to implement is that a component on a page will automatically adopt whatever mode the page around it is set to use. No need to add extra classes, set javascript properties, or add extra media queries inside components.
Design tokens setup#
In order to pull off automatic mode switching, our design tokens in CSS are going to need to be formatted correctly. In fact, the magic is entirely in the CSS of your design tokens. The rest of the page, components and all, just use the design tokens as intended.
Here is the basic structure for design tokens that enables automatic mode switching.
/* product-a-theme.css */
:root {
--color-brand-primary: light-dark( blue, lightblue ); /* light mode, dark mode */
}
The light-dark()
CSS function evaluates the currently active value of the color-scheme
property for an element and then resolves to the correct value accordingly. When color-scheme
is light
, the first value passed to light-dark()
is used. When color-scheme
is dark
, the second value is used instead.
The example above is a bit incomplete, though, because we haven’t told the browser what color-scheme
we want to be active. There are a few ways to tell the browser what color scheme to use. We‘ll build up to a complete example feature by feature.
Change color-scheme according to browser setting#
If we only want to set the mode according to what the browser setting is, we can add the following CSS to our design tokens theme file.
@media (prefers-color-scheme: no-preference) {
:root {
color-scheme: light; /* use "dark" as the default if your site defaults to a dark mode */
}
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
}
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
}
The prefers-color-scheme
setting is the browser setting we‘re acting upon and the media query lets us change CSS depending on the value of that setting.
The media queries above will change the color-scheme
property for the whole page depending on the value of the browser setting for whatever mode the user prefers and will default to light mode if the user has not set a preference.
Change color-scheme with a selector#
Having the color-scheme
property and mode of your site obey browser settings is great, but that’s not necessarily the end of the story. Users like to have fine-grained control over the sites they use. There might be a reason or a situation in which they which to use your site in a mode that contradicts their browser preference.
Letting users contradict their browser setting for color-scheme is a great accessibility win. A simple example is that its easier to read a light mode site in bright sun. Someone that normally likes dark mode might want to temporarily switch to light mode if checking out your site on a sunny day.
So giving users a mode switcher button in addition to obeying browser settings is an accessibility win and a better user experience than just respecting browser settings alone.
So assuming that there is a mode switcher button in your application UI somewhere—one of those fancy animated SVG ones with a sun and moon—that can toggle a selector on the <html>
or <body>
element, we need more CSS to handle the case that the user has clicked the mode switch button.
/* the data-mode attribute goes on the `<html>` tag and is toggled by a button somewhere */
:root[data-mode="light"] {
color-scheme: light;
}
:root[data-mode="dark"] {
color-scheme: dark;
}
I find that attribute selectors—with “data-” or without—work better than classes for setting “state” in CSS where there can only be one state active at a time. Adding and removing classes is more complicated than just changing the value of an attribute, and the value of an attribute selector is going to more closely align with your application‘s internal state better than a class name will.
Using both the media query and the data-mode
attribute creates an order of preferences in your application. The [data-mode]
attribute selector is higher specificity than the :root
selector alone. So, if your application add or sets the data-mode
attribute, the application will respect the users choice regardless of what their browser settings for dark mode are because the data-mode
CSS rules will be applied.
Likewise, if you only want to respect browser choice and not provide a secondary mode switcher button in your application, then the browser choice is respected automatically as soon as the media query is evaluated.
Putting it all together#
So for a “bulletproof” theme setup we would combine all of these approaches into our single theme CSS file as such:
/* product-a-theme.css */
/* BROWSER SETTING */
@media (prefers-color-scheme: no-preference) {
:root {
color-scheme: light; /* use "dark" as the default if your site defaults to a dark mode */
}
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
}
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
}
/* USER INTERACTION */
:root[data-mode="light"] {
color-scheme: light;
}
:root[data-mode="dark"] {
color-scheme: dark;
}
/* TOKENS */
:root {
--color-brand-primary: light-dark( blue, lightblue ); /* light mode, dark mode */
}
We can repeat this structure as needed for Product B with green values for the color tokens, with variants for both light and dark mode.
Just use the tokens normally#
All that is left is for some component to use the tokens as intended. So you will have some component style like:
/* header.css */
header {
color: var(--color-brand-primary);
}
And your header color will automatically switch modes without any extra JS!
This approach is also great for web components#
Another aspect that makes the above approach “bulletproof” is that is also works with web components and shadow dom!
// I use Lit. Lit is great. This example is a Lit example, but you can do something similar in vanilla JS also
import { css } from 'lit';
class MyComponent extends LitElement {
static styles = css`
p {
color: var(--color-brand-primary, blue);
}
`
render(){
return html`<p>I will automatically change colors when the mode switches and tokens are defined.</p>`;
}
}
And there you go! Automatic mode switching in a web component!
The CSS Custom property here is doing all the heavy lifting. CSS custom properties inherit through the shadow root boundary, so they can be defined outside the shadow root of a web component and used inside shadow root styles just fine.
The value of the CSS property resolves with respect to the color-scheme
of the outside page, so the web component styles don’t even need to “know” what color-scheme
is set. The design token CSS custom properties handle everything. No javascript, no extra code, just a sensible, robust design token setup.
My personal recommendation#
Combine your light and dark mode variants into a single design tokens theme file
I have seen a lot of variations on how to accomplish multiple modes on a website. It seems to be a popular approach to have two theme files, one for light and one for dark mode and to switch them out with Javascript when the user changes the them, or when browser setting is detected.
I like the approach of using light-dark()
and color-scheme
because:
- No extra Javascript is needed to switch token values
- No need to worry about hosting and loading a second file
- The size of a single file with
light-dark()
is less than the combined file sizes of two files
Once your mode switching gets more complicated, as in you decide one day to save the mode in localStorage
or something, it‘ll be nice that you don‘t also have to juggle requesting and loading more CSS files. Your mode switcher button can focus solely on changing the CSS selector for the mode the user wants and/or saving the choice somewhere.
The End