If you select one of these options, it will be stored in your web browser and automatically applied to other pages on this website.

Why and how I built a color and contrast switch

How I built a simple theme and contrast switcher for my website with React and Sass

Reading time: 5–8 minutes

A composite screenshot of the top part of this webpage in each of the six implemented color/contrast combinations.

The why

The Web Content Accessibility Guidelines don't say you need a dark mode, or a high contrast mode. That said, some users will have preferences because of disabilities. There are other requirements that the user can customize the colors and sizes. So while not essential, these options seem positive for accessibility.

Theme switchers are nothing new. I used to wonder whether letting the user toggle between dark mode and light mode served any purpose. After all, the prefers-color-scheme media query has very wide support.

But not every user can change the settings - think about locked-down library computers. Not every user knows how to - operating systems hide the options away. And not every user who generally prefers  dark mode will prefer your particular dark mode.

After thinking about this for a while, I decided to letting readers choose, and default to their prefers-color-scheme value.

The newer prefers-contrast media query works in fewer browsers. These settings are even more hidden and many browsers only support more, even though less is valid.

So, I decided I wanted to let users customize contrast, as well as colors.

You can see the result on the top of the page unless you're using forced colors (I'll explain why later). It always overrides the browser preference.

The how

The color schemes

I decided to create six separate styles. They're low, normal and high contrast versions for light and dark mode. I chose, to aim for 4.5:1 on the low contrast versions (so still meeting WCAG 2.1), 10:1 on normal contrast and 17:1 on high contrast. If you don't know what these numbers are, read this introduction from WebAIM, especially the "Contrast Ratio" section.

The CSS

I'm using SCSS, so created a mixin. I give the mixin a property and the colors for each theme, and it creates the CSS, so that:

  • If a combination of data- attributes are present, they take priority
  • If not, the browser preferences (prefers-color scheme and prefers-contrast) are followed
  • If not, light normal is set.
@mixin color-declaration($property, $light-less, $light-normal, $light-more, $dark-less, $dark-normal, $dark-more) {
  // First, when the colors and contrast are forced:
  html[data-override-color-scheme="light"]:not([data-override-contrast="more"]):not([data-override-contrast="less"]) & {
    #{$property}: $light-normal;
  }

  html[data-override-color-scheme="light"][data-override-contrast="more"] & {
    #{$property}: $light-more;
  }

  html[data-override-color-scheme="light"][data-override-contrast="less"] & {
    #{$property}: $light-less;
  }

  html[data-override-color-scheme="dark"]:not([data-override-contrast="more"]):not([data-override-contrast="less"]) & {
    #{$property}: $dark-normal;
  }

  html[data-override-color-scheme="dark"][data-override-contrast="more"] & {
    #{$property}: $dark-more;
  }

  html[data-override-color-scheme="dark"][data-override-contrast="less"] & {
    #{$property}: $dark-less;
  }

  // Next, the contrast is forced, the colors are not:
  html[data-override-contrast="less"]:not([data-override-color-scheme="dark"]):not([data-override-color-scheme="light"]) & {
    #{$property}: $light-less;

    @media (prefers-color-scheme: dark) {
      #{$property}: $dark-less;
    }

    @media (prefers-color-scheme: light) {
      #{$property}: $light-less;
    }
  }

  html[data-override-contrast="more"]:not([data-override-color-scheme="dark"]):not([data-override-color-scheme="light"]) & {
    #{$property}: $light-more;

    @media (prefers-color-scheme: dark) {
      #{$property}: $dark-more;
    }

    @media (prefers-color-scheme: light) {
      #{$property}: $light-more;
    }
  }

  // Now the base case, nothing is forced:
  #{$property}: $light-normal;

  // Since no page preferences are set, observe whatever the browser says.
  @media (prefers-contrast: less) {
    #{$property}: $light-less;
  }

  @media (prefers-contrast: more) {
    #{$property}: $light-more;
  }

  @media (prefers-color-scheme: dark) {
    #{$property}: $dark-normal;

    @media (prefers-contrast: less) {
      #{$property}: $dark-less;
    }

    @media (prefers-contrast: more) {
      #{$property}: $dark-more;
    }
  }
}

It's included like this:

.headings{
    @include color-declaration(
        color,
        hsl(0, 0%, 20%),
        hsl(0, 0%, 10%),
        hsl(0, 0%, 2%),
        hsl(0, 0%, 80%),
        hsl(0, 0%, 90%),
        hsl(0, 0%, 98%),
    );
}

At the moment, there are a few exceptions between the different themes, so I don't use CSS variables for these. I plan to work out the exceptions, and switch to using CSS variables where possible, which would dramatically reduce the CSS from nearly 100 lines to three.

Since this is a relatively simple design, the performance is good, even with a lot of CSS produced.

One final interesting piece of CSS:

div#preferences_container {
  @media(forced-colors: active) {
    display: none;
  }
}

Theme switching has zero effect in forced colours mode (also known as Windows High Contrast Mode). These lines removes the whole switch container from the page, so it never appears.

The switch

The switching widget is a tiny React app. React isn't the best solution for everything, but when I started this project, it provided a good way to handle some of the state management. Since this is a personal project, I don't always choose the best technology for the task. Projects like this are a good way to experiment and learn.

The structure looks a bit like this. The full code is on GitHub.

<FocusTrap active={panelVisible}>
    <button>Page Options</button>
    {panelVisible && <div>
        <Switch>
            <SwitchButton>Dark</SwitchButton>
            <SwitchButton>Match system</SwitchButton>
            <SwitchButton>Light</SwitchButton>
        </Switch>
        <Switch>
            <SwitchButton>More</SwitchButton>
            <SwitchButton>Default</SwitchButton>
            <SwitchButton>Less</SwitchButton>
        </Switch>
        <button>Close</button>
    </div>
    }
</FocusTrap>

The panel behaves like a modal - it traps keyboard focus while it's open because it might be blocking content underneath it. It is closed by clicking the button again, using the close button, or the esc key.

The switches are groups of buttons. Exactly one at a time has aria-pressed="true", which identifies them as toggle buttons.

<button aria-pressed="false">Dark</button>
<button aria-pressed="true">Match system</button>
<button aria-pressed="false">Light</button>

Every change is saved to LocalStorage and applied to the <html> element as data- attributes.

The page

In the head of every page is a tiny bit of render-blocking JavaScript. These two lines load whatever is in LocalStorage and apply it to the <html> element as data- attributes.

document.documentElement.setAttribute('data-override-color-scheme', 
    window.localStorage.getItem('display-override-color-scheme'));
document.documentElement.setAttribute('data-override-contrast', 
    window.localStorage.getItem('display-override-contrast'));

This prevents the user seeing a moment of white background if they prefer the dark colors, even with a slow network connection. We can load the React app in a non-render-blocking way after that, there's no rush to make the switch appear.

What's next?

Like every good personal project, I will keep tinkering with this.

A couple of things I'd like to do next are:

  • Get the text strings out of the React app and into the HTML page for easier tweaking and multilingual support.
  • Move some variables to CSS variables. This needs some design changes, as the current CSS overrides colors in some places.
  • Keep an eye on the script size. If scripts are needed elswhere, it might make sense to recreate this in vanilla JS to lower the overhead.