Creating a dark theme is the easy part. Switching between themes, remembering the choice and following the system preference needs a little JavaScript.
The approach has three states (system, light, dark) but the <html> element only ever gets light or dark in its data-theme-mode attribute. Spruce's dark mode reads that attribute.
Put this small script in the <head> (not deferred), so the page never flashes in the wrong theme:
<script>
(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const getPreferred = () => localStorage.getItem('preferred-theme') ?? 'system';
const apply = (theme) => {
const resolved = theme === 'system' ? (media.matches ? 'dark' : 'light') : theme;
document.documentElement.setAttribute('data-theme-mode', resolved);
};
apply(getPreferred());
// Follow the system in real time while the user is in system mode.
media.addEventListener('change', () => {
if (getPreferred() === 'system') apply('system');
});
})();
</script>A native select is the most accessible control for three options:
<label class="form-label" for="theme-switcher">Theme</label>
<select class="form-control form-control--sm" id="theme-switcher">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>(() => {
const switcher = document.getElementById('theme-switcher');
const media = window.matchMedia('(prefers-color-scheme: dark)');
switcher.value = localStorage.getItem('preferred-theme') ?? 'system';
switcher.addEventListener('change', () => {
const theme = switcher.value;
const resolved = theme === 'system' ? (media.matches ? 'dark' : 'light') : theme;
localStorage.setItem('preferred-theme', theme);
document.documentElement.setAttribute('data-theme-mode', resolved);
});
})();Spruce UI has ready-made versions: the theme switcher button and the theme switcher select.
For images that differ by theme (like a logo), store both sources and swap them when the attribute changes:
<img src="logo-dark.svg" alt="Spruce CSS" data-light-src="logo-dark.svg" data-dark-src="logo-light.svg">(() => {
const root = document.documentElement;
const update = () => {
const theme = root.getAttribute('data-theme-mode') ?? 'light';
document.querySelectorAll('img[data-light-src]').forEach((image) => {
image.src = image.dataset[`${theme}Src`];
});
};
update();
new MutationObserver(update).observe(root, { attributes: true, attributeFilter: ['data-theme-mode'] });
})();For logos, an inline SVG that uses currentColor or Spruce's color custom properties avoids the swap entirely.
The same pattern works for the design styles: store the choice and set the data-spruce-style attribute in the <head> script. This site's header does exactly that.
localStorage stays on the device and never travels to the server, which suits static sites. If you render on the server, a cookie lets you print the right attribute in the HTML. Either way, the stored preference is purely functional; check your local regulations about whether it needs consent.


