Dark Mode
A flicker-free light/dark theme toggle built on CSS custom properties, a data-theme attribute, and localStorage.
Live Implementation
This is the exact toggle from the header — try it right here:
How It Works
Every color on this site is a CSS variable, defined once for light and once for dark, switched by a data-theme attribute on <html>:
:root {
--color-bg: #fff;
--color-fg: #10131a;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0b0d12;
--color-fg: #e7e9ee;
}
}
[data-theme="dark"] {
--color-bg: #0b0d12;
--color-fg: #e7e9ee;
}
[data-theme="light"] {
--color-bg: #fff;
--color-fg: #10131a;
} Switching themes is just flipping that one attribute. No stylesheet swap, no re-render.
A small script sits in <head>, before any CSS paints. It reads your saved preference from localStorage, or falls back to your OS setting, and sets data-theme right away. That’s the whole trick for avoiding the “flash of wrong theme” you see on a lot of sites.
Clicking the toggle button runs a handler in main.js that flips the attribute and saves your choice to localStorage, so it sticks around next time you visit.
Folder Structure
src/
_includes/
layouts/base.njk ← inline anti-FOUC script
components/theme-toggle.njk ← the button + icons
assets/
scss/base/_theme.scss ← the two token sets
js/main.js ← click handler + persistence Important Files
src/_includes/components/theme-toggle.njk
<button
type="button"
class="theme-toggle"
id="theme-toggle"
aria-label="Toggle color theme"
data-theme-toggle
>
<svg class="theme-toggle__icon theme-toggle__icon--sun" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="4.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M12 2.5v2.4M12 19.1v2.4M4.9 4.9l1.7 1.7M17.4 17.4l1.7 1.7M2.5 12h2.4M19.1 12h2.4M4.9 19.1l1.7-1.7M17.4 6.6l1.7-1.7"/>
</g>
</svg>
<svg class="theme-toggle__icon theme-toggle__icon--moon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20.5 14.9A8.5 8.5 0 1 1 9.1 3.5a7 7 0 0 0 11.4 11.4Z"/>
</svg>
</button> src/assets/js/main.js
import "./search.js";
import "./toc.js";
import "./view-counter.js";
import "./copy-code.js";
const themeToggles = document.querySelectorAll("[data-theme-toggle]");
const navToggle = document.querySelector("[data-nav-toggle]");
const primaryNav = document.getElementById("primary-nav");
function setTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem("theme", theme);
} catch {}
}
themeToggles.forEach((toggle) => {
toggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme");
setTheme(current === "dark" ? "light" : "dark");
});
});
navToggle?.addEventListener("click", () => {
const isOpen = primaryNav.classList.toggle("is-open");
navToggle.setAttribute("aria-expanded", String(isOpen));
}); Notes
- Follows your OS setting until you pick a theme yourself, then remembers that instead.
- The toggle is a real
<button>, not a checkbox dressed up to look like one, so screen readers announce it correctly witharia-label. - Since it’s all CSS variables, adding a third theme (like “high contrast”) is just another
[data-theme="..."]block.