Implementing Dark Mode
Published
Last Updated
5 min reading time
Posted in Accessibility, How-to, Process, Web Dev
Tag : CSS
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?#anchor
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#anchor
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#anchor
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.
Visual Depth#anchor
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.
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#anchor
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.
Supporting prefers-color-scheme
and User Preference#anchor
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#anchor
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};
@media (prefers-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)#anchor
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#anchor
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#anchor
Need help with an upcoming project?
I'd love to hear more about what you have coming up and how I can help bring it to life! My earliest availability is currently Q1 2025.
Get in Touch