Theme colour switcher

How to create an accessible dark mode switch for your website

I really appreciate when a website or app offers the choice of dark mode. It makes reading at night much easier on the eyes, and some people prefer it at all times due to vision issues.

I'd never implemented a dark mode switch, so one of my goals for my blog redesign was to do so, both for reader convenience and to gain the experience. Here's how I did it.

It might seem surprising that I discuss accessibility of a colour theme switcher for screen reader users. Aren't screen reader users blind, making this switcher unnecessary? While most screen reader users are blind, it is a misconception that all are. See Adrian Roselli, "Not All Screen Reader Users Are Blind" and the latest WebAIM screen reader user survey results.

HTML

The first consideration is, what is the dark mode switch HTML? Semantic markup is very important particularly for users of assistive technology. It isn't, or shouldn't be random tags, it has meaning and that meaning is conveyed to users. This helps them understand what a thing is and how to use it.

I decided a dark mode switch was probably best represented as a radio button group, as it's a set of binary choices where only one can be selected at a time. The other choice is, of course, the regular theme.

Many sites use a checkbox, which I think is a fine choice as well.

Those radio buttons are contained in a fieldset, preceded by a visually-hidden H2 heading so screen reader users can easily find it.

(Note that to be considered a group where only one can be selected, radio buttons must be given the same name.)

<div class="block--themeswitch">
    <h2 class="visually-hidden">{{ 'Theme colour switcher'|t }}</h2>
    <fieldset class="themeswitcher">
        <div class="lightmode">
            <input id="lightmode" type="radio" name="switch" value="lightmode"></input>
            <label for="lightmode">
                <span class="visually-hidden">{{ 'Light mode'|t }}</span>
            </label>
        </div>
        <div class="darkmode">
            <input id="darkmode" type="radio" name="switch" value="darkmode"></input>
            <label for="darkmode">
                <span class="visually-hidden">{{ 'Dark mode'|t }}</span>
            </label>
        </div>
    </fieldset>
</div>

The double curly brackets are Twig templating language syntax; if you're not working in Drupal or Twig, replace or remove as needed.

CSS

We then need a bit of CSS trickery to transform the fieldset and radio buttons into a more visually pleasing element. Here's where we could easily go astray. We don't want the radio buttons or labels to be visible, but it's very important not to hide them from users of assistive technology with display: none or visibility: hidden. We also need to make sure the radio buttons remain clickable and selectable.

I couldn't hide the labels using the visually-hidden class, as I used them to display the graphics and the yellow halo that indicates which one is selected. So I wrapped the label text in a <span> and put the visually-hidden class on the span.

I made the checkboxes themselves opacity: 0, and using absolute positioning, height, width, and z-index, positioned them over and covering the labels and images. That way the whole thing is clickable and selectable.

Since the checkboxes aren't visible, we can't forget to add a focus indicator outline to the label when the checkbox is focused. Focus indicators are crucial for users who navigate by keyboard.

The end result looks like this (with dark mode selected):

Theme switcher widget with dark mode theme selected

Here's the relevant SCSS:

.visually-hidden {
  position: absolute !important;
  height: 1px;
  width: 1px;
  overflow: hidden;
  clip-path: inset(1px 1px 1px 1px);
  opacity: 0;
}

.themeswitcher {
    background: $purple;
    border-radius: 25px;
    position: relative;
    height: 44px;
    width: 120px;

    > * {
        position: absolute;
    }

    .lightmode {
        left: 0;
    }

    .darkmode {
        right: 0;
    }

    input[type="radio"] {
        opacity: 0;
        position: absolute;
        width: 100%;
        height: 100%;
        z-index: 2;
    }

    input[type="radio"] + label {
        display: inline-block;
        position: relative;
        width: 44px;
        height: 44px;
    }

    input[type="radio"] + label {
        margin: 0;

        &:before,
        &:after {
            display: block;
            position: absolute;
            background-repeat: no-repeat;
            background-position: center center;
        }

        &:before {
            width: 54px;
            height: 54px;
            background: $yellow;
            border-radius: 50%;
            top: -5px;
            left: -5px;
        }

        &:after {
            content: '';
            width: 44px;
            height: 44px;
        }
    }

    input[value="lightmode"] + label:after {
        background-image: url(PATH/TO/LIGHTMODE/GRAPHIC);
    }

    input[value="darkmode"] + label:after {
        background-image: url(PATH/TO/DARKMODE/GRAPHIC);
    }

    input[type="radio"]:focus + label {
        outline: 4px dotted $focus-outline-color;
        outline-offset: 5px;
    }

    input[type="radio"]:checked + label {
        &:before {
            content: '';
        }
    }
}

body.darkmode {
    .themeswitcher {
        background: $purple-gray-light;
    }
}

JavaScript

Next, we need to add the interactivity that makes the switch function. When the darkmode switch is selected, we add the "darkmode" class to the <body> tag. When lightmode is selected, we remove the class. We store the user's choice in localstorage, so each time they visit a new page or come back to the website, it will be applied. We make sure the relevant radio button is always selected, so screen reader users know which mode is active, and our visual halo indicator is always applied to the correct option. We do this on page load and each time the theme switcher radio buttons receive the "change" event, indicating the user has made a selection.

I ended up having to refactor the JS a bit due to issues on mobile browsers, which I'll explain further on.

(function setMode() {
    const darkmode = window.localStorage.getItem('darkmode') || null;

    if (darkmode === 'true') {
        document.body.classList.add('darkmode');
    }
})();

document.addEventListener('DOMContentLoaded', function() {
    const radios = document.querySelectorAll('input[name="switch"]');
    const lightRadio = document.querySelector('input#lightmode');
    const darkRadio = document.querySelector('input#darkmode');

    if (document.body.classList.contains('darkmode')) {
        darkRadio.checked = true;
    } else {
        lightRadio.checked = true;
    }

    radios.forEach(function(radio) {
        radio.addEventListener('change', function() {
            const value = this.value;

            if (value === 'darkmode') {
                window.localStorage.setItem('darkmode', 'true');
                document.body.classList.add('darkmode');
            } else {
                window.localStorage.setItem('darkmode', 'false');
                document.body.classList.remove('darkmode');
            }
        });
    });
});

Oh no FOUC!

While my code worked fine, I was getting a very pronounced FOUC (flash of unstyled content) while the page loaded. The darkmode class would be applied only after everything else loaded, resulting in a significant delay during which the regular theme colours were visible.

I was able to reduce FOUC by putting themeswitch.js in the page <head> and loading it asynchronously. This, however, broke the switch functionality in some mobile browsers. I had to refactor the JS to check the value of the darkmode local storage entry on page load, but wait for the DOMContentLoaded event to run the functions which select the correct radio button and switch the theme on change. This sets the darkmode class almost immediately, but makes sure DOM elements are present before calling JS on them.

Here's how to do that in Drupal 8 and 9: define a new library in your THEMENAME.libraries.yml, add the themeswitch.js file (and CSS if you go the route of a dedicated darkmode CSS file), give the library the "header: true" flag, and add the "async" attribute to the JS:

darkmode:
  header: true
  js:
    js/themeswitch.js: {attributes: { async: true }}

Don't forget to call the library in your THEMENAME.info.yml file:

libraries:
  - THEMENAME/darkmode

Darkmode CSS

Of course, simply applying a class to the body will not transform your website; you have to write the CSS which will be applied when the darkmode class is active. This can be a lot of work and there are various ways to go about it. My site is pretty small so I just used the whack-a-mole approach: overwriting colours in each SCSS file as needed. I think I could have done so more efficiently with utility classes and mixins; at some point I'll probably go back and clean up the code a bit.

SVGs

My site uses several SVGs, and those needed to change colour as well. Depending on how they're implemented, there are various ways to do that. For background images and list markers, where changing SVG colours is not so well supported, I just created a second version of the image.

For embedded SVGs with more than one colour, like the site logo, I stripped out colour definitions from the SVG, put classes or IDs on the SVG paths, and targeted them with CSS, e.g.:

#logo1 {
    fill: #F8DB76;
}

#logo2 {
    fill: #A22CC9;
}

.darkmode {
    #logo1 {
        fill: #745F7E;
    }
   
    #logo2 {
        fill: #F8DB76;
    }
}

For embedded SVGs with one colour, I gave them the fill="currentColor" attribute and allowed them to inherit their parent element's text colour.

And that's it! If you have any questions or spot anything that could be improved, please let me know in the comments.

Tags:

Add new comment

Plain text

  • No HTML tags allowed.
  • Lines and paragraphs break automatically.