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.