Editorial cover graphic with toggle interaction motif in gold on cream.

Building a theme switcher without third-party toggle libraries

A theme switcher that lives entirely in CSS and JavaScript, no external dependencies, no flash of unstyled content.

A client wanted a dark mode toggle on their university site. I opened the browser, looked at the usual suspects — switch libraries, toggle widgets — and realised every one of them was adding weight to solve a problem that CSS and a tiny amount of JavaScript could handle directly.

The result is genuinely simple. No npm packages. No JavaScript framework. No flash of unstyled content when you refresh the page. Just CSS variables, a localStorage key, and about forty lines of JavaScript that runs synchronously before the page paints.

How it works

CSS variables hold the colour tokens. JavaScript checks localStorage for a theme preference and applies a class to the document root. The styles respond to that class. When someone clicks the toggle, JavaScript saves the preference and reapplies the class.

styles/theme.css
:root {
--color-bg: #f5f1e5;
--color-text: #1a1a1a;
--color-accent: #b8860b;
}
:root[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-text: #f5f1e5;
--color-accent: #ffd700;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
}

The JavaScript that runs on load happens before the page renders:

src/theme-init.js
// This runs in the <head> to block rendering until done.
// It prevents a flash of the wrong theme.
(function() {
const stored = localStorage.getItem('theme-preference');
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
const initial = stored || (prefersLight ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', initial);
})();

That script is inlined in the <head> and runs synchronously. By the time the CSS loads, the theme class is already applied. No flash, no jank.

The toggle component

The toggle itself is a button with an accessible label:

<button
id="theme-toggle"
aria-label="Toggle dark mode"
aria-pressed="false"
>
<svg aria-hidden="true" width="24" height="24">
<use href="#icon-moon"></use>
</svg>
</button>

The JavaScript that runs after page load:

src/theme-toggle.js
function init() {
const toggle = document.getElementById('theme-toggle');
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
toggle.setAttribute('aria-pressed', current === 'light');
toggle.addEventListener('click', () => {
const theme = document.documentElement.getAttribute('data-theme');
const newTheme = theme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme-preference', newTheme);
toggle.setAttribute('aria-pressed', newTheme === 'light');
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

The toggle updates aria-pressed so screen readers hear the state change. That is the constraint I did not anticipate: making the button’s state clear to assistive technology without doubling the DOM.

What I would still do

This pattern does not handle high contrast mode or other system preferences beyond light/dark. If someone has set their browser to prefer-reduced-motion, the theme should probably not animate. A production implementation would listen to that media query too:

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
document.documentElement.style.transition = 'background-color 0.3s, color 0.3s';
}

I also still feel uncertain about forcing dark mode on when the user has not expressed a preference. Right now it defaults to system preference, which feels safer — if someone has not clicked the toggle, their system preference is probably the most honest signal of what they want. But I have watched enough users not realise they can change it that I wonder if the default should be more obvious.

One more thing I did not get right the first time: the toggle should probably appear earlier in the page. It is hidden until JavaScript runs, which means if someone has scripts blocked, they have no way to toggle. A more resilient approach would render the toggle as HTML, hide it with no-js class, and show it when JavaScript confirms it will work.

For a university site where accessibility is non-negotiable and performance is carved in stone, this approach holds up. The whole thing is 2KB of code after minification, runs in under 5ms, and breaks gracefully if JavaScript fails to load.

What this came from