Beginner

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 with aria-label.
  • Since it’s all CSS variables, adding a third theme (like “high contrast”) is just another [data-theme="..."] block.