Frey·ja

Implementing Light Mode in Simple, Modern CSS and JavaScript

The default dark color theme for this site is a deliberate aesthetic choice – something fitting of my black sheep persona, and a place to experiment with big, bold electric neons that harken back to the 80s.

But that said, there’s a reason practically every site online that involves a lot of text uses black font on a white background. The contrast between black-on-white and white-on-black is the same, but the latter is harder to read for most people, and unforgiving on the eyes.

Starting with Mac OS Mojave’s introduction of dark mode on the desktop, it became fashionable for sites to start offering dark mode. The W3C even introduced a CSS media query (@media (prefers-color-scheme: dark)) for them to detect the host operating system’s setting of light or dark and skin themselves accordingly.

I already have a default dark mode, so I put in a light mode variant instead. This article isn’t a tutorial for how to do that, but more of an architectural summary to highlight how various advances in CSS and JavaScript over the last few years have it easy.

HTML5 introduced the concept of data-* attributes. These are a very simple, but quite important idea – they’re a namespace on DOM elements for an application to user-specified data. In other words, attributes available on any HTML element to store whatever information you want.

This was technically always possible because HTML/JS/web browsers are very forgiving and just kind of let you do anything, but data-* gave us an official way to do it that was guaranteed not to conflict with any formally defined attribute names.

We define one called data-theme that may be empty (for the default theme), or hold a value for light or dark, depending on the active mode. We then specify a CSS selector on <html> elements where data-theme has been set to light:

html[data-theme="light"] {
    --background_color: #fff;
    --code_background_color: #eee;
    --font_color: #222;
    --highlight_color: #000;
    --neon_color0: #20a2df; /* slightly darker */
    --neon_color1: #00cf00; /* slightly darker + slightly more green */
}

Which leads into the next feature that made light mode easy: CSS variables. In the CSS above, we don’t set a single style property. Instead, we give new values to custom names prefixed with a double hyphen (--). Those are CSS variables.

Each one has a default value in :root for the site’s normal dark mode:

:root {
    --background_color: #000;
    --code_background_color: #40404c;
    --font_color: #ddd;
    --highlight_color: #fff;
    --neon_color0: #40c2ff; /* blue-ish */
    --neon_color1: #abff00; /* green-ish */

    ...
}

Those are overridden by the more specific data-theme="light" selector when light mode is engaged. Further down in the stylesheet, each variable’s value is used in its relevant elements:

html {
    background: var(--background_color);
    color: var(--font_color);

    ...
}

code, pre {
    background: var(--code_background_color);

    ...
}

Prior to CSS variables, we would’ve had to pollute the stylesheet with [data-theme="light"] overrides for every element we cared to style for the light theme. With CSS variables, all light mode styling is in exactly one place, keeping the stylesheet short, clean, and maintainable.

The toggle sliders like this one might look like an advanced hack-y widget that needs to import some third-party library to work, but they’re surprising simple. Each one is a checkbox restyled with CSS, and which uses a couple tricks to complete their look:

<label class="theme_switch" for="theme_switch_toggle" title="Toggle light/dark mode">
  <input id="theme_switch_toggle" type="checkbox">
  <span class="slider">
</label>

I won’t recopy the entirety of the CSS here (pop open this site’s stylesheet if you’re curious), but the highlights are:

But that nice, smooth toggle animation must be difficult right? Wrong! The web browser does all the hard work for us here. The animation is a simple transform + translateY (or translateX if it’s a horizontal toggle):

.theme_switch input:checked + .slider:before {
    transform: translateY(
        calc(
            var(--theme_switch_length) -
            var(--theme_switch_dot_size) -
            2 * var(--theme_switch_padding)
        )
    );
}

I use CSS variables in mine to easily resize sliders if I want to try something new, but that entire expression could be simplified to just transform: translateY(17px).

By now you’re supremely tired of me overusing the word “simple”, but once again, it describes the project’s JavaScript nicely. This is a bit of a code dump, but just keep in mind that it does these basic things:

const DOC_DATA_ATTR_THEME = 'data-theme';
const LOCAL_STORAGE_KEY_THEME = 'theme';

function getThemeToggleSwitches() {
    return document.querySelectorAll('.theme_switch input[type="checkbox"]');
}

// Sets a single on/off value for toggle switches on the page.
function setThemeToggleSwitches(checked) {
    getThemeToggleSwitches().forEach(function(toggleSwitch) {
        toggleSwitch.checked = checked;
    });
}

// Attached to toggle switch changes. Sets theme and puts
// it in local storage.
function switchTheme(e) {
    if (e.target.checked) {
        document.documentElement.setAttribute(DOC_DATA_ATTR_THEME, 'light');
        localStorage.setItem(LOCAL_STORAGE_KEY_THEME, 'light');
    }
    else {
        document.documentElement.setAttribute(DOC_DATA_ATTR_THEME, 'dark');
        localStorage.setItem(LOCAL_STORAGE_KEY_THEME, 'dark');
    }
    setThemeToggleSwitches(e.target.checked);
}

// Runs on initial page load.
window.addEventListener('DOMContentLoaded', function () {
    getThemeToggleSwitches().forEach(function(toggleSwitch) {
        toggleSwitch.addEventListener('change', switchTheme, false);
    });

    ...
}

A few minor notes on the “modern” aspect of this:

JavaScript aficionados may have noticed one more thing in the code above that I didn’t mention yet: local storage.

Being able to select a theme is nice, but we don’t want to make the user have to do it every time they come back to visit. Local storage is used to make a value persistent, even without requiring login or any other server-side code.

When a new theme is selected, we save it under a local storage key, which the user’s browser will remember:

localStorage.setItem(LOCAL_STORAGE_KEY_THEME, 'light');

The next time they load a page, we check to see if there’ anything in local storage, and if so, set the current theme and state of toggle switches to that value:

// Runs on initial page load.
window.addEventListener('DOMContentLoaded', function () {
    ...

    const currentTheme = localStorage.getItem(LOCAL_STORAGE_KEY_THEME)
        ? localStorage.getItem(LOCAL_STORAGE_KEY_THEME)
        : null;

    if (currentTheme) {
        document.documentElement.setAttribute(DOC_DATA_ATTR_THEME, currentTheme);

        if (currentTheme === 'light') {
            setThemeToggleSwitches(true);
        }
    }
}

Local storage differs from a cookie in that a browser isn’t obliged to send it on every single request (including those for assets like images and stylesheets 2) for the benefit of the server. Instead, its values are only read as necessary when client-side JavaScript asks for it, making it an excellent fit for what we’re trying to do here.

I started working on a light theme assuming that this would be an unpleasant project that would end up eating multiple days of my time, but was surprised when I got the entire thing done in only a few hours.

Roll back a few years and that wouldn’t have been possible. JavaScript has always been powerful enough to make it work, but between concerns around cross-browser compatibility, third-party libraries to do the hard stuff, and low-level hacks, it would’ve taken days to get it right. Those days are over.

Hopefully some of you found that useful :) If not, here’s one more toggle, just because. You’re welcome.

1 The toggle in the header is a <div> because I want it to display as a block, while the toggle inset into the paragraph above is a <span> because it should be inline.

2 A common “hack” for a decade or so was to make sure that assets were served from a separate domain to cut the overhead of sending cookies on every single one of those requests. Needless to say, this site needs no such technique.

October 21, 2020 (4 years ago) by Frey·ja