It was a dark and stormy night...

How to use CSS to add dark mode to your website

When using the dark mode of macOS 10.14 or Windows 10, visiting a website with a light UI can be a jarring experience. Safari Technology Preview 68 introduces a new CSS media query that gives the option for websites to adjust their appearance based on user preferences. This setting is on a standardization track, and we can expect other browsers and other platforms to adopt it eventually.

I’ve added it to mathlive.io and you can to. It’s straightforward and fun!

Switching between the Light and Dark appearance in System Settings > General toggles the appearance of the website.

Refactoring your stylesheet

The first thing to do is to isolate the relevant color settings from your CSS stylesheet. CSS Variables are a convenient way of doing this.

CSS variables are custom-named CSS properties that start with -- (double-dash), and whose value is accessed using the var() pseudo-function. They are “scoped” to the elements on which they are defined. The body element is a good choice for a “global” scope.

This refactoring is a good opportunity to think semantically and potentially reduce you overall color palette. Give names to your variables that reflect how they are used structurally, not their actual values. For example, use surface for your main color background, not light-grey.

I’ve adopted the convention of using the on- prefix to indicate “foreground” colors, for example surface is the overall background color of the page and on-surface is the color used to draw text and icons on that background.

body {
    --surface: #fafafa;        
    --surface-border: #fff;
    --editable-surface: #fff;
    --editable-surface-border: #fafafa;
    --secondary: #f2f2f2;
    --secondary-border: hsl(0,0%,91%);
    --on-surface: hsl(var(--hue),19%,26%);
    --link: hsl(var(--hue),40%,49%);;
    --primary: hsl(var(--hue), 40%, 50%);
}

Using the hsl() function helps clarify the relationship between colors. You’ll frequently need a color variation that is the same hue, but with increased or decreased saturation or value. The hsl() function makes that explicit.

It’s also convenient to define a --hue CSS variable, and use this variable in the definition of other variables. CSS variables can be nested!

Go through your stylesheet and replace hard-coded values with their corresponding CSS variable.

body {
    --surface: #fafafa;        
    --surface-border: #fff;
    --editable-surface: #fff;
    --editable-surface-border: #fafafa;
    --secondary: #f2f2f2;
    --secondary-border: hsl(0,0%,91%);
    --on-surface: hsl(var(--hue),19%,26%);
    --link: hsl(var(--hue),40%,49%);;
    --primary: hsl(var(--hue), 40%, 50%);
}

.input-field {
        background: var(--editable-surface);
        color: var(--on-surface);
        border: 1px solid var(--editable-surface-border);
        border-radius: 2px;
}

Everything should still look exactly like it did before. But that’s about to change.

Duplicate the block that defines the CSS variables, and change them to reflect your dark appearance. Rather than simply swapping light and dark value, I find that a bit of tinting tends to work well with dark themes. For example, the --surface is defined as a desaturated (19%) and darkened (26%) version of the hue, instead of the monochromatic version used in my light theme.

Media query

Wrap this new block in a media query: @media(prefers-color-scheme: dark). This media query will get triggered only if the browser knows about it, and if the user has selected the dark appearance as their overall system theme.

Note that the --hue variable is not redefined in the block of the media query, since we want the value to be shared between both themes.

body {
    --hue: 206;
    --surface: #fafafa;        
    --surface-border: #fff;
    --editable-surface: #fff;
    --editable-surface-border: #fafafa;
    --secondary: #f2f2f2;
    --secondary-border: hsl(0,0%,91%);
    --on-surface: hsl(var(--hue),19%,26%);
    --link: hsl(var(--hue),40%,49%);;
    --primary: hsl(var(--hue), 40%, 50%);
}
@media (prefers-color-scheme: dark) { body {
    --surface: hsl(var(--hue),19%,26%);
    --surface-border: hsl(0,0%,20%);
    --editable-surface: #333;      
    --editable-surface-border: hsl(0,0%,13%);
    --secondary: hsl(var(--hue),25%,35%);
    --secondary-border: hsl(var(--hue),19%,26%);
    --on-surface: hsl(0,0%,98%);
    --link: hsl(var(--hue),36%,84%);
}}

If you open your page in a browser that supports it, your design will now toggle between dark and light when the system settings is changed. 👍

Images

Things are looking pretty good, but some of the images in the page stand out.

You can correct this by using CSS Filters. Apply an invert(100%) filter with a blend mode of screen to invert all the colors. However, you only want to invert the light values of the image, but keep the color hues. Applying a hue-rotate(180deg) filter will bring back the hues to their original value. For consistency, apply a “multiply” blend mode to the light theme.

img {
    mix-blend-mode: multiply;
}
@media (prefers-color-scheme: dark) { img {
    filter: invert(100%) hue-rotate(180deg);
    mix-blend-mode: screen;
}}

This may not work for all cases, but it can help for some content to blend in better, without having to duplicate and redo all the assets.

Theme switcher

All the above is great if you have an OS and browser that support theme switching, but it doesn’t do anything if you don’t. Let’s change that. We’ll add a button to manually switch between themes.

Since we won’t be able to rely on media queries, we’ll need another mechanism to toggle. We’ll use an HTML attribute on the body element. Unfortunately, we’ll have to duplicate the block that defines the CSS variables for our dark theme and apply an attribute selector [theme="dark"] to the body selector.

With this, we can switch between a “dark” and “light” theme by setting the value of the theme attribute on the body element.

But there’s a third possible value which is to follow the system settings and is represented by the absence of a theme attribute. Therefore, we must indicate that the media query we had previously does not apply when the user has selected the systemwide dark theme but not the set the “light” theme for the page, that is:

@media (prefers-color-scheme: dark) { body:not([theme="light"]) {
    --surface: hsl(var(--hue),19%,26%);
    --editable-surface: #333;      
    --editable-surface-border: hsl(0,0%,13%);
    --secondary: hsl(var(--hue),25%,35%);
    --secondary-border: hsl(var(--hue),19%,26%);
    --on-surface: hsl(0,0%,98%);
    --link: hsl(var(--hue),36%,84%);
}}
body[theme="dark"] {
    --surface: hsl(var(--hue),19%,26%);
    --editable-surface: #333;      
    --editable-surface-border: hsl(0,0%,13%);
    --secondary: hsl(var(--hue),25%,35%);
    --secondary-border: hsl(var(--hue),19%,26%);
    --on-surface: hsl(0,0%,98%);
    --link: hsl(var(--hue),36%,84%);
}

Finally, we’ll need a bit of JavaScript to toggle between the dark and light themes, and reset to the system default.

function switchTheme(ev) {
    // If the alt/option key is pressed, reset to system default
    if (ev.altKey) {
        document.body.removeAttribute("theme");
        return; 
    }

    const prefersDark = window.matchMedia && 
        window.matchMedia("(prefers-color-scheme: dark)").matches;

    let theme = document.body.getAttribute("theme");
    if (theme === "dark") {
        theme = "light"; 
    } else if (theme === "light") {
        theme = "dark";
    } else {
        theme = prefersDark ? "light" : "dark";
    }
    document.body.setAttribute("theme", theme);
}

To detect if the current system settings is dark mode, use window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches. Note the spelling of “color” and the required parentheses around the media query.

Conclusion

Supporting a dark theme is not a lot of work and it will be appreciated by users who prefer this setting.

It’s unfortunate that the syntax of media queries lead us to having to duplicate some CSS code (you can’t do @media (prefers-color-scheme:dark), body[theme="dark"] {}) but I couldn’t find a way around it.

As a next step, you could also support multiple tints. Even though macOS and Windows support tinting (accent colors), those values are not (yet?) available through CSS but they can be implemented separately.

Try it now by visiting mathlive.io and click on the 🎨 icon to switch between themes.