Previously I wrote about how to decide to add a dark mode to a product and what to consider when designing a dark mode . I made some comments about how I implemented it on this website. I made several improvements after that, so I’m sharing my learnings here.

Of course, visitors should be able to manually select a theme if my guess is wrong. Finally, I added a transition for when the theme changes. This is also done with a CSS class added to the root element.

The themes are activated by CSS classes on the root element, <html> . When the page is loaded, I want to apply the theme that most likely suits the visitor (you!) best. After all, most people don’t like configuring websites before they can read a blog post, so the the whole theming feature would likely remain unused otherwise. So I have to make a guess about what the visitor wants and expects. I do that in this order:

I added theming with a mix of and CSS. In this post I’ll go step by step into the details of how I did it and what I learned.

Adding a dark mode is basically adding a theme. The principles are the same for adding a light mode to a dark website or alternative styling based on user-defined variables, the time of year or holidays.

Saving and loading state

When a visitor navigates from page to page, the theme shouldn’t change. That’s why I save the state of the theme selection, so it can be loaded by the next page. After having considered some alternatives (see below), I’ve landed on saving the selected theme to local storage.

Every time a page is loaded, in the current or a new tab, it checks if a theme was set previously. Because the preference for a light or dark theme can change during the day, with every change, I add a time stamp to the saved setting. Only when the state was saved less than two hours ago, it’s applied:

function returnThemeBasedOnLocalStorage () { const pref = localStorage . getItem ( ' preference-theme ' ) const lastChanged = localStorage . getItem ( ' preference-theme-last-change ' ) let now = new Date () now = now . getTime () const minutesPassed = ( now - lastChanged ) / ( 1000 * 60 ) if ( minutesPassed < 120 && pref === " light " ) return ' light ' else if ( minutesPassed < 120 && pref === " dark " ) return ' dark ' else return undefined }

When the visitor manually changes the theme in one tab, all other tabs should change with it. To achieve that, I added an event listener for changes to local storage. The cool thing is that that event listener only fires in other tabs. Browsers assume that the application is aware of changes in the active tab already. Thanks for pointing that out, Max Freundlich.

function syncBetweenTabs (){ window . addEventListener ( ' storage ' , ( e ) => { if ( e . key === ' preference-theme ' ){ if ( e . newValue === ' light ' ) enableTheme ( ' light ' ) else if ( e . newValue === ' dark ' ) enableTheme ( ' dark ' ) } }) }

Infinite loop warning The event listener in inactive tabs is somewhat slow to react. Without safeguards, the following could happen when several tabs are open: The visitor manually switches the theme several times, quickly.

The other tabs detect the changes with varying delays.

While one tab is changing to dark mode, another is changing to light mode.

If all tabs are saving the state they transition to at different moments, they keep on reacting to each other and change themes endlessly. So that’s why I don’t save the state when reacting to a state change in local storage.

Discarded solutions

We could use URL parameters to save the state, but that would mean the selected theme would be passed on to other people when links are shared to the pages. OS-level preferences are ignored when pages are opened that way, or via bookmarks.

The simplest solution for saving state during the session only is using the browser’s session storage. It’s the less known variant of local storage, except that it’s cleared when the session ends. The drawback of that is that if a page is opened in a new tab, it doesn’t know about the previously used theme.

Check for OS-level preferences

If we can’t choose a theme based on a saved state from a recent visit, we can check the OS’s setting. We can use the CSS media feature prefers-color-scheme . It can have one of three values:

dark

light

no-preference

As far as I know, the easiest or only way to check the visitor’s preference with is to test if one of the values matches:

function returnThemeBasedOnOS () { let pref = window . matchMedia ( ' (prefers-color-scheme: dark) ' ) if ( pref . matches ) return ' dark ' else { pref = window . matchMedia ( ' (prefers-color-scheme: light) ' ) if ( pref . matches ) return ' light ' else return undefined } }

Do pages now react twice to the same change? If multiple tabs with the site are open, inactive tabs will pick up the OS-level change twice. First the change in prefers-color-scheme triggers a theme change and then the local storage event listener picks up the change saved by the other tabs. In practice that’s not an issue. After all, I just change some CSS classes upon a theme change. Applying the same change a second time has no effect.

Choose a theme based on the time of day

The prefers-color-scheme feature has solid support on the evergreen desktop browsers and iOS 13 but Edge and several mobile browsers don’t support it yet. As a fallback, I want to apply the dark theme between sunset and sunrise. According to my analytics, most visitors comes during office hours, so I didn’t want to make this too advanced and just assume the sun sets at 20:00 and rises at 5:00. Every day of the year.

function returnThemeBasedOnTime (){ let date = new Date const hour = date . getHours () if ( hour > 20 || hour < 5 ) return ' dark ' else return ' light ' }

Let visitors manually choose a theme

Despite my best efforts, my guess for what theme visitors want may be wrong. So I added a button for each theme to my page template. Because I only have a light and a dark theme, I hide the button of the currently active theme.

<html class= "theme-light" > <form class= "theme-selector" > <button aria-label= "Enable light theme" aria-pressed= "false" role= "switch" type= "button" id= "theme-light-button" class= "theme-button enabled" onclick= "enableTheme('light', true)" > Light theme </button> <button aria-label= "Enable dark theme" aria-pressed= "false" role= "switch" type= "button" id= "theme-dark-button" class= "theme-button" onclick= "enableTheme('dark', true)" > Dark theme </button> </form> <!--- Rest of the website ---> </html>

To make sure the buttons aren’t shown where is not supported, they’re hidden by default. My then unhides it on page load.

As you can see, there are two ARIA properties to make the buttons accessible. To be honest, I’m not sure how useful they are. The theming is all about styling that is irrelevant to most people with vision bad enough to need a screen reader. Then again, I can imagine that there are people with a visual impairment who do have a preference for one theme or the other and use a screen reader to compliment their visual abilities.

Style the page based on the selected theme

So I apply the theme by changing the classes on the root element, but what does exactly happen in CSS? I found that using CSS variables are great to make that switch. That way, I can change the variable once and have many components react to it. Combined with SCSS, you get something like this:

$theme-light-text-color : #111 ; $theme-dark-text-color : #EEE ; @mixin color ( $property , $var , $fallback ){ #{ $property } : $ fallback ; // This is a fallback for browsers that don't support the next line. #{ $property } : var ( $ var , $ fallback ) ; } p { @include color ( color , -- text-color , $theme-light-text-color ); } .theme-dark { --text-color : #{ $theme-dark-text-color } ; }

In this example, the light theme is used as a default. The interesting part is that when the CSS variable --text-color is not set, the fallback for it is used. When the class theme-dark is added to the root, the variable is defined and applied. Of course I didn’t come up with that trick myself. I recommend taking a look at Andy Clarke’s article about dark modes and this theming example for more details. There’s also also Wei Gao’s interesting approach to create a dark mode with blending modes.

Transition between themes

A transition between the themes makes switching less jarring. That can be straight-forward, but I already had elements with transitions. Their transition-duration s are much shorter than the duration of the theme change. When elements change color at different paces, that looks almost as bad as without a transition. I described my workaround in Dark mode design considerations, so I’ll skip it here.

The actual theme switching

Putting all of the above together, I wrote this function for applying a theme:

function enableTheme ( newTheme = ' light ' , withTransition = false , save = true ){ // Collect variables const root = document . documentElement let otherTheme newTheme === ' light ' ? otherTheme = ' dark ' : otherTheme = ' light ' let currentTheme ( root . classList . contains ( ' theme-dark ' )) ? currentTheme = ' dark ' : ' light ' // Transitions aren't added on page load if ( withTransition === true && newTheme !== currentTheme ) animateThemeTransition () // Set the theme root . classList . add ( ' theme- ' + newTheme ) root . classList . remove ( ' theme- ' + otherTheme ) // Update the controls let button = document . getElementById ( ' theme- ' + otherTheme + ' -button ' ) button . classList . add ( ' enabled ' ) button . setAttribute ( ' aria-pressed ' , false ) button = document . getElementById ( ' theme- ' + newTheme + ' -button ' ) button . classList . remove ( ' enabled ' ) button . setAttribute ( ' aria-pressed ' , true ) // Save the state if ( save ) saveToLocalStorage ( ' preference-theme ' , newTheme ) }

Browser support

The solutions I described above use modern browser features, most notably the media query for prefers-color-scheme and CSS variables. I’ve also used some modern style . The CSS variables are key. Current browsers that support it, also support the other essential features. I added a CSS media query to only show the theme selection buttons in browsers that support those:

.theme-selector { display : none ; } @supports ( ( --a : 0 )) { .theme-selector { display : block ; } }

It’s not a perfect check, because Safari started supporting CSS variables in version 9.1 and arrow functions only in version 10. We’re at version 13 now, so that’s not really an issues for my small audience.