KeesTalksTech finally has a dark theme! It was one of those items that had been on my wishlist for years. Last week I've implemented the new Mona Sans font by GitHub, so it felt natural to see if I could create a dark mode for my blog.
In a previous version of this article I've used cookies and a class to activate dark mode, in this new article I'll focus on just a dark stylesheet that's optionally activated or not. It makes maintenance of the styles way easier, as there are no classes involved.
A simple stylesheet for darkness..
My dark theme is super easy. Why? My site already uses CSS variables for its colors. My main challenge was the SVG logo: how do I change its colors? Instead of including the logo as an image, I have now included it as an SVG element in the DOM, so I can tweak the coloring of the elements with a stylesheet.
My dark.css stylesheet looks like this:
:root {
--background-color: #161B22;
--foreground-color: #ddd;
--secondary-color: #B0B0B0;
--soft-color: #333;
--light-background: #282C34;
--footer-background: #111;
}
#site-title svg path[data-label],
#site-title svg path[fill="#fff"]{
fill: var(--foreground-color);
}To make it work "out-of-the-box", I only need to include it in the <head> after my main styles:
<link rel="stylesheet" id="ktt-style-dark" href="dark.css" media="(prefers-color-scheme: dark)" />When a user that has the preference for dark mode, she will get it.
But I want to force a mode...
Now, here is where it gets tricky, because now we have to code. Let's first distinquish 3 modes: auto (based on user preference), light (selected), dark (selected). We just got the auto version to work with the media property of the stylesheet.
Now we need to do the following:
- When the user selects light, we need to disable the dark stylesheet. My default theme is light and the user will get a light experience even if the preference of the browser is set to dark.
- When the user selects dark, we need to enable the dark stylesheet and remove the media attribute. The sheet now takes precedence over my default light theme variables.
- The user preference can be stored in local storage and should be applied every time we load a new page.
Add a dark script
We're going to add some scripting to:
- Change the mode to dark, light or auto
- Load the previous mode on a new page
This script should also be loaded in the head, right after the dark stylesheet, so it is executed directly when the page is loaded. This will prevent flashes between page navigations.
<link rel="stylesheet" id="ktt-style-dark" href="dark.css" media="(prefers-color-scheme: dark)" />
<script data-cfasync="false" src="dark.min.js"></script>The data-cfasync="false" will prevent Cloudflare from optimizing the script. For more info, check the documentation.
The button
Let's build a button that uses Material Icons to display the current state: brightness_auto (brightness_auto), dark_mode (dark_mode) and light_mode (light_mode). This button had ID dark-mode-toggle in my setup. The name of the icon will serve as state.
<a id="dark-mode-toggle" href="#" onclick="toggleMode(); return false;">brightness_auto</a>
Toggle
Clicking the button will toggle the state. We'll add a data-initial-choice on the button, so we can determine the preference when the page loaded (we'll implement it later). Let's make it intuitive for the end-user, so when the button is clicked, something should always be happening. The trick is to get the brightness_auto in the right position of "next states".
function toggleMode() {
const button = document.getElementById("dark-mode-toggle");
const initialChoice = button.getAttribute("data-initial-choice");
const prefersDarkMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
let modes =
initialChoice == "light_mode" || (!initialChoice && prefersDarkMode)
? ["light_mode", "dark_mode", "brightness_auto"]
: ["dark_mode", "light_mode", "brightness_auto"];
const currentMode = button.innerText;
const nextMode = modes[(modes.indexOf(currentMode) + 1) % modes.length];
setMode(nextMode);
const modeChangedEvent = new CustomEvent("themeChanged", {
detail: { previousMode: currentMode, mode: nextMode, prefersDarkMode }
});
document.dispatchEvent(modeChangedEvent);
}Now, we only need a function that applies the mode:
function setMode(mode) {
localStorage.setItem("dark-mode-toggle", mode);
let sheet = document.getElementById("ktt-style-dark");
switch (mode) {
case "brightness_auto":
sheet.setAttribute("media", "(prefers-color-scheme: dark)");
sheet.disabled = false;
break;
case "dark_mode":
sheet.disabled = false;
sheet.removeAttribute("media");
break;
case "light_mode":
sheet.disabled = true;
break;
}
const button = document.getElementById("dark-mode-toggle");
if (button) {
button.innerText = mode;
}
}
Restore choice upon page load
There is only one chore left, and that's restoring the user choice for local storage upon page load:
(function () {
const choice = localStorage.getItem("dark-mode-toggle");
// no choice? just let the defaults be
if (!choice || choice == "brightness_auto") {
return;
}
setMode(choice);
document.addEventListener("DOMContentLoaded", () => {
const button = document.getElementById("dark-mode-toggle");
button.setAttribute("data-initial-choice", choice);
button.text = choice;
});
})();Putting it all together
When we put it together, and we do some further refactoring we'll get this script:
(function (window, document, localStorage) {
const AUTO = "brightness_auto",
LIGHT = "light_mode",
DARK = "dark_mode",
LOCAL_STORAGE_KEY = "dark-mode-toggle",
TOGGLE_BUTTON_ID = "dark-mode-toggle",
INITIAL_CHOICE_ATTRIBUTE = "data-initial-choice";
function setMode(mode) {
localStorage.setItem(LOCAL_STORAGE_KEY, mode);
let sheet = document.getElementById("ktt-style-dark");
switch (mode) {
case AUTO:
sheet.setAttribute("media", "(prefers-color-scheme: dark)");
sheet.disabled = false;
break;
case DARK:
sheet.disabled = false;
sheet.removeAttribute("media");
break;
case LIGHT:
sheet.disabled = true;
break;
}
const button = document.getElementById(TOGGLE_BUTTON_ID);
if (button) {
button.innerText = mode;
}
}
window.toggleMode = function () {
const button = document.getElementById(TOGGLE_BUTTON_ID);
const initialChoice = button.getAttribute(INITIAL_CHOICE_ATTRIBUTE);
const prefersDarkMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
let modes =
initialChoice == LIGHT || (!initialChoice && prefersDarkMode) ? [LIGHT, DARK, AUTO] : [DARK, LIGHT, AUTO];
const currentMode = button.innerText;
const nextMode = modes[(modes.indexOf(currentMode) + 1) % modes.length];
setMode(nextMode);
const modeChangedEvent = new CustomEvent("themeChanged", {
detail: { previousMode: currentMode, mode: nextMode, prefersDarkMode }
});
document.dispatchEvent(modeChangedEvent);
};
const choice = localStorage.getItem(LOCAL_STORAGE_KEY);
// no choice? just let the defaults be
if (!choice || choice == AUTO) {
return;
}
// restore previous choice
setMode(choice);
// set right text to the button after the DOM has loaded
document.addEventListener("DOMContentLoaded", () => {
const button = document.getElementById(TOGGLE_BUTTON_ID);
button.setAttribute(INITIAL_CHOICE_ATTRIBUTE, choice);
button.text = choice;
});
})(window, document, localStorage);Further reading
While working on this topic, I found some excellent sources for reading:
- The Automatic Disqus Theme Switching: discusses how to change the theme of your Disqus comments when you switch the theme. I've implemented the
themeChangedevent, so my code should be compatible with the solution described in the blog. - Previous article: uses classes, cookies and query-strings. Formed the basis for this article.
- WAVE browser extension: get more insights into the accessibility of your web project, right from your browser.
- Tanaguru Contrast-Finder: awesome tool that helps to select better contrasting colors (helps with accessibility).
- Iconify.design: when you have multi color (SVG) icons, you might want to change the individual colors. This tool does an awesome job at it and even generates the data:image CSS embed code.
Changelog
- added Iconify.design.
- fixed the
toggleModefunction. The markup was eaten up by the blog. - added accessilbility
- added the further reading section.
- implemented the
themeChangedevent. - rewrite with KeesTaksTech as use-case.
- initial article.