Skip to main content

Making a Theme Switch to Override Dark Mode

I'm a little sensitive to bright light and I find that I manage better when my OS is set to dark mode. My eyes don't get as tired and I can focus more when I'm working in my more productive hours at night.

There are some times when light text on a dark background is harder to read. Most often it's because of a text-heavy page on a technical site that I'm focussing extra hard on. (Usually confused 😕.)

It really does help if there's a theme-switcher on the website itself, but if not, there's always the override in the browser dev tools.

A clipped screenshot of the Firefox developer tools. The light/dark mode toggle is highlighted. There is a tooltip that says, 'Toggle light colour scheme simulation for the page'.

It was important to me to consider this as a requirement when I started building this website. Even if it's only to help me when I'm writing and reviewing the content.

# The Approach

At the time of writing, there's no way (I can find) to programmatically toggle the browser's colour scheme preference. Our approach will use a data attribute on the root (<html />) element that we can use as a CSS selector to override some CSS custom properties.

<html lang="en-GB" data-prefers-theme="light">
<!-- Snip! -->
</html>

We'll add a toggle to our UI that will trigger a change to the data attribute. We'll store the chosen value in localStorage so that the site can remember the setting between page views and visits.

# Setting the Default Theme

This is a short script that we'll put in the <head> of our page. I've inlined this instead of putting it in a remote file to get it to load as quickly as possible.

<script>
// Put a function on the window object to handle our update process.
window.updateThemePreference = function () {
// If they've been here before, they might have a setting saved
const themeOverride = localStorage.getItem("prefersTheme");

// Get the User's preferred colour scheme from their browser settings
const defaultPreference =
matchMedia && matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";

// Prefer the override over their browser defaults & save it to the window
window.themePreference = themeOverride || defaultPreference;

// Set a data attribute on the <html /> tag to use in our CSS later.
document.documentElement.dataset.prefersTheme = themePreference;
};

// Call our function straight away to initialise things.
updateThemePreference();
</script>

This snippet creates a new function we can use to repeat this initialisation process whenever we want to. It checks the value we store in localStorage and uses the browser preference as a fallback.

This value is put in a global variable on the window object we can re-use in our scripts, and sets the data-prefers-theme attribute on our <html /> tag.

# Styling the Override

Here's where we'll create the styles we want for each theme. With the advent of CSS Custom Properties (I can't help but call them CSS Variables), there are all sorts of nice, new ways to handle style overrides.

Here, I've set up two sets of colours and have defaulted the site to the "light" theme. There's a fallback for non-JS users that uses the browser preference, and the root selector that makes the rest work.

:root {
/* Some variables we'll use around our stylesheets */
--light--background-color: #f2f2f2;
--light--color: #222;

--dark--background-color: #06273b;
--dark--color: #bccdd8;

/* Our default light theme */
--canvas--background-color: var(--light--background-color);
--canvas--color: var(--light--color);
}

/* If our JS breaks, this will keep our dark mode support going. */
@media (prefers-color-scheme: dark) {
/* But only if we haven't chosen to override it with our working JS... */
:root:not([data-prefers-theme="light"]) {
--canvas--background-color: var(--dark--background-color);
--canvas--color: var(--dark--color);
}
}

/* No matter our browser setting, a chosen dark theme should always be applied. */
:root[data-prefers-theme="dark"] {
--canvas--background-color: var(--dark--background-color);
--canvas--color: var(--dark--color);
}

/* Whatever setting gets picked, we'll use it here */
html {
background-color: var(--canvas--background-color);
color: var(--canvas--color);
}

# Triggering the Switch

Now we have everything set up, we need something that will toggle between the two options. I'm using a pair of radio inputs but, with some extra accessibility considerations, a button would work too.

Here's our code. I've made this look like a toggle switch using CSS, but you could swap this for button-looking labels or anything else you would prefer.

<div class="ThemeToggle" id="js--theme-preference">
<h2 class="visually-hidden">Colour preference options</h2>

<input
class="ThemeToggle__input"
type="radio"
id="theme-preference--light"
name="theme-preference"
value="light"
/>

<label
class="ThemeToggle__label"
for="theme-preference--light"
title="Switch to a light theme."
>

<span class="ThemeToggle__icon">☀️</span>
<span class="visually-hidden">Light</span>
</label>

<input
class="ThemeToggle__input"
type="radio"
id="theme-preference--dark"
name="theme-preference"
value="dark"
/>

<label
class="ThemeToggle__label"
for="theme-preference--dark"
title="Switch to a dark theme."
>

<span class="ThemeToggle__icon">🌙</span>
<span class="visually-hidden">Dark</span>
</label>
</div>

At the end of the document, I've added a script tag that will set the initial state and watch for a change event. This stores the new choice in localStorage and calls our update function to update our preference and the data attribute on the <html /> tag.

<script>
// First, initialise the current active state on page load.

// Find the switcher container
const $switch = document.getElementById("js--theme-preference");

// Get the inputs inside...
const $inputs = $switch.querySelectorAll("input");

// ...and find the current setting.
const $initialInput = $switch.querySelector(`[value="${themePreference}"]`);

// Make it the active.
$initialInput.checked = true;

// Then, when the document is loaded...
document.addEventListener("DOMContentLoaded", function () {
// Add Event Listeners to watch each input for a change.
$inputs.forEach(($input) => {
$input.addEventListener("change", (ev) => {
// The value is set to 'light' or 'dark'
const { value } = ev.target;

// Update our preference and...
localStorage.setItem("prefersTheme", value);

// ...trigger the style change.
updateThemePreference();
});
});
});
</script>

This is only one approach of many. Some people prefer to toggle a class on the body, others load alternate stylesheets. If you would like to see the full code, the repository is open and hosted on GitLab.