Implementing Dark Mode

I love getting feedback from folks using the Be Inclusive app, my favorite feedback by far is actionable suggestions to make the app better. One such wonderful suggestion — and the topic of this post — that I've recently taken the time to implement is a Dark Mode theme. It was wrought with tons of twists and turns, and ended up taking a lot more thought and effort than I had anticipated at the start, so I'm documenting the process including initial setup, major necessary changes, and how I worked in support for both the browser native prefers-color-scheme as well as a toggle to choose between the default and dark mode on the user profile page. At the end of this post, I also have a list of resources that were extremely helpful in getting things set up. So let's dive in!

Why Implement Dark Mode? #

First things first, gotta ask the obvious question of why we should take the time to work in dark mode. Other than it being all the rage for at least a few years now, darker themes are also helpful for reducing eye strain and for people with low vision or photosensitive conditions. This alone was reason enough for me to set some time aside for this.

Getting Started #

I had mentioned in the intro that I didn't anticipate this taking very long, that's because I had already spent a considerable amount of time setting up a color scheme fully defined in SCSS variables. Initially, I naively assumed that swapping these colors would get me most of the way there. Boy was I wrong, let's take a look at all the issues I came across.

SCSS Variables #

The first issue I realized I had was that SCSS variables wouldn't let me swap colors without having basically all styles duplicated for dark and light modes, yikes. After a bit of research to allay my concerns about browser support, I added another layer to my stylesheet organization that set up CSS Custom Variables with a fallback to the light theme values for browsers that don't support variables.

This ended up being just one additional SCSS file, but it also necessitated my changing all references to individual SCSS color variables into references to the new Custom Variable instead. As you can imagine, this affected essentially every single stylesheet but was still relatively straightforward. For example $c-primary changed to var(--c-primary).

Once the leg work was done to convert to custom variables, I then adjusted the colors to be more dark mode friendly, it took a lot of adjustments but the image below shows the final comparison between the same named colors between light and dark mode.

Itemized list of all major colors used across the site, left is light mode and the right is the finalized dark mode.
Light and Dark mode examples of sitewide color swatches placed side by side

Visual Depth #

I rely heavily on drop shadows to give the perception of depth across the whole site, turns out drop shadows for depth on dark mode looks either like absolute garbage or is not visible at all in the first place. Live and learn. After much consideration and reading (check the resources below for some great detailed case studies on depth in dark backgrounds), I replaced the drop shadows with increasingly lighter background colors for higher elevations.

Various levels of depth supported sitewide, left is the default with dropshadow and right is the dark mode relying more heavily on background colors instead.
Light and Dark mode examples of sitewide depth placed side by side

This reliance on background color changes also led to a great deal more testing in two key areas:

  • Ensuring a minimum contrast between the background and the content is maintained since the background color is no longer static.
  • Check across multiple screens including mobile devices with brightness turned down to ensure that the differences in depth are perceptible enough.

Tweaks, Tweaks, Tweaks #

Even with the intense adjustments made to the underlying colors, there were still a dozen or so situations where the light mode styling was just not suitable for a dark mode. Generally anywhere that relied on gradients and different colors to visually discern between elements fell flat. Either it became too busy, unreadable, or just plain visually unappealing.

Below is the evolution of alert styles, for example. The light mode version of errors, info, success, and warning alerts is on the left. Center is the same styled alerts with dark mode colors, obviously not ideal as the content won't be readable on the right half and the gradients took on a weird alternating color that looked odd. After several tweaks, the best result I found was to remove the gradients altogether and instead increase the border thickness to still call attention to these important messages.

Three versions of alert styles, left is the light mode, center is dark mode before any tweaks (not great), right is after several iterations of tweaking to make them look okay on a darker bavckground.
Three versions of alert styling for errors, info, success, and warnings. Left shows what they look like on a light background heavily relying on gradients and thin borders. Center shows the same alerts in dark mode with no tweaks, difficult to read and too busy. Right is the finalized version with no gradients and thicker borders.

Supporting prefers-color-scheme and User Preference #

There were two important use cases I wanted to ensure I covered:

  • Make sure to adhere to prefers-color-scheme first, and especially if JavaScript is disabled.
  • Give users the option to choose separately from their current prefers-color-scheme setting.

As it turns out, supporting both these situations called for a bit of duplicative styling. Since they were both very important to me, I opted to figure out a way to keep the source code DRY even though the compiled styles have some duplication.

The Problem Supporting Both #

To change colors and apply specific styles supporting dark mode with prefers-color-scheme, you can easily use an @media query like this:

--c-primary: #{$c-primary};

@media (prefers-color-scheme: dark) {
  --c-primary: #{$c-darkmode-primary};
}

This works great for that, but doesn't support user-selected themes. There are many ways to keep track of users theme selections, I opted for storing in localStorage close to how it's implemented here on this excellent CSS Tricks article. I have a small piece of JavaScript that then grabs that value from localStorage and adds a data attribute to the <html> element. With that setup, we can change styles based on user selection with the same basic code from above, like this:

--c-primary: #{$c-primary};

[data-color-scheme="dark"]
  --c-primary: #{$c-darkmode-primary};
}

Problem is, there's no great way to do both of those together because one is a selector on a parent and the other is a media query. My solve was to place all dark mode specific styles in a dedicated _dark-mode.scss partial and then import that into both methods. Here's a simplified example:

@media (prefers-color-scheme: dark) {
  html.no-js {
    @import "dark-mode";
  }
}

[data-color-scheme="dark"] {
  @import "dark-mode";
}

You might have noticed that the prefers-color-scheme snippet also has another selector inside checking specifically for html.no-js, this is because we don't want these settings to override the users preference in the case where their operating system is set to dark mode but their preference for this site is for light mode. Since the user preference is applied with JavaScript, we fall back to prefers-color-scheme when JavaScript is disabled instead. Part of the small JavaScript snippet noted above also removes the no-js class from the HTML element as it adds the data-color-scheme attribute.

A bit of a bummer having the contents of _dark-mode.scss in my styles twice but it's currently just a couple hundred lines of CSS coming in at less than 10kb before being minified, so the overall impact is small enough to be worthwhile for the additional support.

Avoiding Flash of Incorrect Theme (FOIT) #

Also referred to as Flash of Default Theme (FODT), this is conceptually similar to the classic Flash of Unstyled Content (FOUC) but specifically referring to the potential for the default (light) theme to be displayed briefly while the user preference has yet to be defined.

With the above JavaScript solution in place I got everything I was looking for, the site theme starts with the prefers-color-scheme value and then persists the users selection when changed. Great! But in testing on some slower machines and mobile devices I started noticing that the lighter default theme was getting applied briefly before the dark mode kicked in. Especially not ideal for the very users I implemented this feature for, people with photosensitivity seeing a flash of the lighter theme first wouldn't likely appreciate that.

So how'd I fix it? I broke one of the cardinal rules of JavaScript and placed this theme-based script in the <head> before the stylesheets. In this case I felt it was the right thing to do, it's a very small amount of JavaScript and it completely removed the FOIT in subsequent tests.

Putting it All Together #

Because I've got a lot going on in my stack for Be Inclusive that complicates the underlying implementation I've noted thusfar, I opted to create a bare-bones example for you in a CodePen. I tried to cram everything into one pen so there are some minor deviations, but I hope this gets the point across!

See the Pen Dark Mode Toggle by Steve Woodson (@stevenwoodson) on CodePen.

References #