While I haven't quite finished some preparations for this site, I've been itching to share stuff anyways, so here's a short bit on how to add a dark mode to your static site. Besides allowing a visitor to explicitly choose a theme, I also take note of a third option where the site automatically matches the browser's theme. You're going to need an okay grasp on CSS and Javascript to understand what's going on. If you're still learning, I beg of you to use the tutorial on MDN instead of W3Schools or some other site if you aren't already.
The CSS
There are four cases we must take into account when defining our CSS declarations: explicit light mode, explicit dark
mode, browser light mode, and browser dark mode. The explicit cases can be handled with a class and a media query for
the browser theme. If you want to add a class for the media queries, you should also add it in the HTML (e.g.
<body class="browser">
) so that the theme is selected by default just in case the Javascript fails.
body.dark {
background-color: black;
color: white;
}
body.light {
background-color: white;
color: black;
}
@media (prefers-color-scheme: dark) {
body {
background-color: black;
color: white;
}
}
@media (prefers-color-scheme: light) {
body {
background-color: white;
color: black;
}
}
Even with only two declarations per colour scheme, the need to duplicate them means this will get very long very quickly, and it becomes more error prone to modify since we may forget to change one of the duplicates. You could be wise and devise a solution with Javascript, or you can be me and wing it with CSS without thinking it through. Here's a solution that's even longer and only partially solves the error-proneness. On the plus side, the theme will default to the browser's if the Javascript doesn't execute for some reason.
The way I set things up was to use variables. For those not aware, CSS variables allow values to be referenced later. The variables have two layers of indirection. The first layer defines the actual values for each colour scheme, and the second layer selects which value should be used. There are multiple choices for the second layer which are selected based on the class or media query.
body {
--dark-mode-body: black
--dark-mode-text: white;
--light-mode-body: white;
--light-mode-text: black;
background-color: var(--body-color);
color: var(--text-color);
}
body.dark {
--body-color: var(--dark-mode-body);
--text-color: var(--dark-mode-text);
}
body.light {
--body-color: var(--light-mode-body);
--text-color: var(--light-mode-text);
}
@media (prefers-color-scheme: dark) {
body {
--body-color: var(--dark-mode-body);
--text-color: var(--dark-mode-text);
}
}
@media (prefers-color-scheme: light) {
body {
--body-color: var(--light-mode-body);
--text-color: var(--light-mode-text);
}
}
A single variable corresponds to a single colour with this CSS structure, but the tradeoff is the increased complexity that makes adding colours more difficult.
The Javascript
Somewhere in your HTML, there should be three <button>
elements with the attribute
type="button"
: one for light mode, one for dark mode, and another for the browser. If your site has a
consistent header or footer, that will likely be the best place for them. Giving them an ID isn't necessary, but it
makes modifying them in Javascript easier. Each button will get an event listener that will change the
<body>
's class. If you need a refresher on events, here's
the MDN article.
// Light mode example
document.getElementById("light-mode-button").addEventListener("click", () => {
document.body.className = "light";
});
I used className
here since switching out the theme class is more straightforward. Because modifying
className
applies to the entire class attribute value, this won't work if you have other classes on your
<body>
. Consider using
classList
in that situation.
A mechanism also needs to be added for the site to remember which theme the visitor chose. The
Web Storage Application Programming Interface (API) will be suitable for this task. For the site to remember the
theme setting after the page is closed, the localStorage
object will be used. Values can be stored with
the setItem()
method, which associates a key name with the value. The value can then be retrieved at
a later time by passing the key name to the getItem()
method.
const initialTheme = localStorage.getItem("theme") ?? "browser";
document.body.className = initialTheme;
document.getElementById("light-mode-button").addEventListener("click", () => {
document.body.className = "light";
localStorage.setItem("theme", "light");
});
// ...
The API returns null
if a key was not added, which will occur when a visitor visits a page for the first
time. The two question marks, called the
nullish coalescing operator, provide a default value in that case. The second line will set the class of
<body>
when the page first loads.
Because the script requires accessing elements in the HTML, it should be executed after the browser has finished
parsing the HTML file. My preferred way is to put it in the <head>
with the defer attribute.
<head>
...
<!-- theme.js is just an example. You can put the source wherever you wish. -->
<script src="/theme.js" defer></script>
</head>
Anyone who's been designing their site with accessability guidelines in mind will also want to implement the aria-pressed attribute for each button.
Media Queries via Javascript
My brilliant CSS probably isn't an appealing option for some, so here's how to do it in Javascript. If you
choose to use this method, the CSS only needs to define the light and dark mode classes and not the media queries, and
the HTML must have one of the two classes explicitly set in case the Javascript fails.
Later Correction: I assumed that both themes needed to be part of a class, but I forgot that it's perfectly fine for
one theme not to be in a class, as the classed theme will have higher precedence if active. That means, say, the CSS
for the light theme can be defined in the body
selector, and the dark theme in the body.dark
selector. This set up alleviates the need to add a class directly in the HTML.
const initialTheme = localStorage.getItem("theme") ?? "browser";
const isLightModeBrowser = matchMedia("(prefers-color-scheme: light)").matches;
const browserTheme = isLightModeBrowser ? "light" : "dark";
document.body.className = initialTheme === "browser" ? browserTheme : initialTheme;
document.body.getElementbyId("browser-mode-button").addEventListener("click", () => {
document.body.className = browserTheme;
localStorage.setItem("theme", "browser");
});
// ...
I should probably explain what's going on. The first line is the same as before. The second line uses a media query to
check if the browser's theme is light or dark. The isLightModeBrowser
variable is a boolean. If the
browser theme is light mode, then the value is true. Otherwise, the browser theme is dark mode, so the value is
false. The third line converts the boolean value into a string variable, browserTheme
, that can be used
for setting classes. The weird question mark colon syntax is
the conditional operator.
Whenever the browser theme needs to be set, the browserTheme
variable should be used. For setting the
class of <body>
on page load, a conditional operator is set up so that only "light"
and "dark"
are returned. Inside the event listener for the browser mode button,
document.body.className
is assigned the browserTheme
variable and not "browser".
Fixing the Browser Theme Flash
If you've managed to follow along, then everything should be working. However, if you refresh the page with the theme
set opposite to the browser default, you may have noticed the page very briefly show the browser theme. This is pretty
nasty for those using dark mode since they might get flashbanged every time they visit a new page. The reason why this
is happening is because the browser begins rendering the page before the script adds the class to
<body>
. The theoretical solution is to add the script inside <body>
at the very
end.
<body>
...
<script src="/theme.js"></script>
</body>
</html>
The idea is that the browser isn't supposed to begin rendering until it's done reading the HTML. In theory, because
the browser has to execute the script before it can continue, <body>
will have its class set before
it's rendered. In reality, browsers are actually extremely eager to get any stuff onto the screen, so the browser will
flash its default theme while it's partially through the HTML, and this solution won't work even though we technically
followed the rules. Therefore, the class needs to be changed before the browser has a chance to render anything.
<head>
...
<script src="/theme.js" defer></script>
</head>
<body>
<script>
const initialTheme = localStorage.getItem("theme") ?? "browser-mode";
document.body.className = initialTheme;
</script>
...
</body>
The lines that initially change the <body>
's class are removed from the source file and placed
right at the beginning. While you could put the code in a separate file, it's small enough to leave out in the open.
Based on my tests with Edge (Microsoft branded Chrome) and Firefox, the browser theme flash hopefully shouldn't happen
anymore . If you're using a browser that still has this issue, then, uh, I've got nothing. ☹️