How to Sync Your React App with the System Color Scheme

A Dark Mode Implementation Guide for React

Glad Chinda
Bits and Pieces

--

Following the trend these last couple of years, you can agree that dark mode design is gradually becoming a thing. Most of the popular applications, and even devices, have rolled out releases that feature a dark mode (night mode) color scheme.

Dark mode emphasizes the use of light-colored text and elements on a dark background — while maintaining good contrast. The reasoning behind this is somewhat straightforward — viewing the content in dark mode is not just easier on the eyes but also conserves device battery, as expressed by this Popular Science article.

TL;DR

  • Building apps with dark mode theming has become pretty common these days, hence it makes sense to understand some of the major ingredients needed to implement it — themes, switch, and context.
  • Simply letting the user toggle between light and dark modes via a simple switch can be counter-intuitive, especially when the user has a preferred color scheme already configured on the device or browser. Hence the need to detect and stay in sync with the user’s preferred color scheme on the device.
  • For React applications running on the web, CSS media queries alongside the window.matchMedia API can be leveraged for detecting and observing changes to the user’s preferred color scheme. React Native 0.62, on the other hand, ships with the Appearance API and the useColorScheme hook for the same purpose.
  • Depending on the app requirement, it is possible to use a combination that involves both inheriting the user’s preferred color scheme set on the device and allowing the user to manually override the app’s theme at any time, via some preferences.

This post will not focus on building a design system or a UI library that enables theming. I recommend you learn it by looking at others' design systems. You can use cloud component hubs like Bit.dev to explore components from different component libraries.

When you start building your own themeable UI components, make sure to publish them yourself to Bit.dev. This way you’ll have your own “toolbox”, a collection of reusable and themeable UI components to use in your future apps.

Example: exploring React components published on Bit.dev

Dark Mode Implementation

There are so many great dark mode implementations out there, and it has become very common these days to see web applications (such as blogs) include some kind of widget (like a switch) for toggling between viewing their content in either light mode or dark mode.

A blog with Dark Mode toggle widget

Let’s examine a typical dark mode (color scheme) implementation for a React application. We will focus on three (3) major ingredients:

1. Themes
2. Switch
3. Context

Themes

Configure styles for both dark theme and light theme. Also, define mechanism for persisting current theme (e.g using local storage). Here is what the theme.js module looks like:

Switch

Define the mechanism for switching between both themes. The mechanism should be available to the user via a dark mode toggle switch that can be interacted with. Here is what the DarkModeToggleSwitch component looks like:

Context

Switching between themes should also change the app-level theme context, thereby causing the app to re-render with the appropriate styles. Here is the main App.js component that handles the context switching logic and passes it down the whole app.

Device Consideration

Most devices these days (smartphones, tablets, laptops, etc) provide a setting for enabling the dark mode color scheme at a system-wide level. Some even provide additional options for scheduling when to enable dark mode — usually dependent on the time of the day.

Also, we now have browsers providing browser-level color scheme settings either as a built-in feature, via a browser extension, or via some preference simulator. Usually, the browser-level color scheme tends to override the system-wide color scheme setting on the device.

While it can be quite useful to allow the user to quickly toggle between light mode and dark mode with a simple switch at the app-level, it might be counter-intuitive sometimes, especially when the user already has a system-wide color scheme set on the device or at the browser-level.

Hence, being able to know the preferred color scheme of the user’s device or browser becomes very important. Thankfully, CSS media queries together with the window.matchMedia Web API has got us covered.

Color Scheme Detection

The CSS Media Queries Level 5 specification adds the prefers-color-scheme feature that reflects the user’s desire to view the page’s content in a light or dark color theme. The possible values for this feature as defined in that specification are as follows:

  • dark — User prefers a dark color theme
  • light — User prefers a light color theme
  • no-preference — User hasn’t indicated any preference
CSS prefers-color-scheme media feature

Detecting the user’s preferred color scheme and modifying the styles appropriately is pretty straightforward using CSS. However, that isn’t so helpful to us when building React apps that are aware of the user’s preferred color scheme. For that, we will need the means to detect the color scheme from JavaScript code — that’s what window.matchMedia is for.

Staying in Sync

While detecting the user’s current preferred color scheme is an important first step, staying in sync with changes to the user’s color scheme preference is as important — to ensure the app responds or re-renders accordingly to reflect the changes.

One way to go about this is to set up a media query change listener. When window.matchMedia is called, it returns a MediaQueryList object. We have already seen the matches property of this object — which returns a boolean value that indicates whether the media query matches.

However, we can also register a change event listener on the MediaQueryList object to detect when the media query no longer matches.

Now that we have a pretty solid idea of how to detect and stay in sync with the user’s preferred color scheme, we can proceed with refactoring our initial React application — to make it aware of the user’s device color scheme.

Hooking up React

Currently, switching the theme of our React app requires user interaction (click interaction) with the dark mode toggle switch. However, we want to refactor the application to inherit the user’s preferred (system-wide) color scheme as it has been set on the device.

Wait a minute, that will mean we no longer need the dark mode toggle switch. The app should be able to detect and stay in sync with the system-wide color scheme. To achieve this, we will aggregate all the techniques we’ve just seen about color scheme detection into a custom React hook — which we will call useColorScheme.

The useColorScheme Hook

Before we dive into defining the useColorScheme custom React hook, let’s create the module with a couple of helper functions as follows:

Here, we have defined two helper functions:

  • resolveTargetColorScheme — Accepts a target color scheme as its argument and returns either 'dark' or 'light'. Notice that if the specified target color scheme is 'no-preference' or cannot be parsed into any of the schemes in the COLOR_SCHEMES list, then the DEFAULT_TARGET_COLOR_SCHEME value is returned instead.
  • getCurrentColorScheme — This returns an object with its query property set to a MediaQueryList object that matches the current user preferred color scheme, and its scheme property set to the name of the current color scheme as it appears on the COLOR_SCHEMES list. Notice that the function caches the MediaQueryList objects to avoid re-creating them each time.

Now, here comes the useColorScheme hook function:

Quite a number of interesting things are going on inside this useColorScheme hook. Let’s quickly run through them from top to bottom.

  • Using the useRef() hook, create two mutable ref objects: isMounted (holds a boolean value indicating that the component is still mounted) and colorScheme (holds the current color scheme object returned from a call to the getCurrentColorScheme function).
  • Resolve targetScheme by calling the resolveTargetColorScheme function with what was passed to the hook as the target color scheme. Notice the use of the useMemo() hook to ensure that the value is not recomputed except the hook is called with a different target color scheme value.
  • The initial scheme state function calls the getCurrentColorScheme function and initializes the colorScheme ref object with the returned object. It then sets the initial scheme state to the value of the returned object’s scheme property.
  • The useEffect() hook is used to initialize the isMounted ref object to true when the component is mounted and also to set up a change listener on the MediaQueryList object of the current colorScheme ref object. The cleanup function sets isMounted to false and removes the previously set change listener. Notice that the useEffect() side-effect will be called only once since the dependencies list is an empty array ([]).
  • The schemeChangeHandler function runs when the user’s color scheme has changed. When executed, it removes the change listener from the current MediaQueryList object, updates the colorScheme ref with the new object returned from getCurrentColorScheme, updates the scheme state if the component is still mounted, and finally sets up a change listener on the new MediaQueryList object of the current colorScheme ref object.
  • Finally, the hook returns a boolean — true if the current scheme state matches the color scheme resolved as targetScheme, and false otherwise.

That was a mouthful. With the useColorScheme hook in place, all that remains now is to modify the App component from before to use the hook.

Modify the App Component

After making a couple of modifications to the App component, we should be good to go. Here is what the updated App.js module is expected to look like:

Notice that we are no longer rendering the dark mode toggle switch. Also, there is no longer a toggle function for switching between color schemes. Switching between color schemes is now as easy as changing the color scheme preference on either the device or the browser (if available), and then the app updates accordingly.

An aside on React Native

Before we move ahead, let’s briefly consider how we can apply the above concept to React Native apps. Interestingly, react-native from version 0.62 now ships with the Appearance API and the useColorScheme hook — which is very useful for detecting and staying in sync with the user-preferred color scheme.

  • Detecting the current user preferred color scheme will require using the getColorScheme method of the Appearance API as follows:
  • Staying in sync with changes to the user preferred color scheme will require using the useColorScheme hook as follows:

If you are using a version of react-native that was released prior to version 0.62, the react-native-appearance package can be used as an alternative.

Best of Both Worlds

With the new modifications, our React app is now able to change its theme to reflect the user’s preferred color scheme at the device or browser level. However, it has completely lost the ability for the user to manually override the app theme at any time. Now the question is: “Why have either when we can have both?”

Dark Mode toggle widget (with system color switch)

For that to happen, we will need to refactor our React application to support both behaviors. This will require that we maintain an additional setting on the app to enable the user to decide if he wants to inherit the system-wide color scheme or manually override it.

First, let’s begin the refactoring with the theme.js file from before. Here is what the updated module will look like:

Here, we are exporting a new InheritSystem object for getting and updating the theme.inherit_system setting value. Notice how the __themeSettingFactory__ function was used as a convenience function for creating the settings objects.

Next, we will extract all color scheme logic from the App component into a custom React hook — which we will call useTheme. Here is what the hook looks like:

In the useTheme hook, we are bringing in everything we have done so far into one place. Notice how we determine the theme by first checking the inheritSystem state to determine if we should use the dark state from the user’s device or the user’s manually set darkMode state.

Also, we defined a __stateToggleFactory__ higher-order helper function for creating toggle functions for the two states: darkMode and inheritSystem.

The useTheme custom hook returns an object with theme, darkMode and inheritSystem properties. Both the darkMode and inheritSystem properties are objects themselves with two properties: on — which is the boolean value of the state, and toggle — which is the toggle function for the state.

Finally, we will go ahead and refactor the App component from before to use the useTheme hook we just created. The final App.js file should now look like this:

Notice that the App component now looks cleaner and less bloated. Also, we have removed the rendering logic for the color scheme controls — they can be rendered in any way as required for the application.

Final Note

At last, the article comes to a close. In the course of this article, we’ve been able to consider how we can factor in the user preferred system-wide color scheme into our React apps. We’ve also been able to consider how we can bring color scheme manual override functionality into the mix.

While localStorage was used throughout this article for persisting preference values, you are not in any way limited to using it. Based on your app requirements, it is possible to get and update preference values via the traditional HTTP Cookies, an API Service, or any other form of storage mechanism available to you.

Rendering logic has been skipped throughout the article to keep the code snippets as concise as possible. In a real app, you are free to handle rendering as you deem fit or based on your app requirements. Also, you are at liberty to manage theming with whatever package works for you — for example, using styled-components theming.

Learn More

--

--

JavaScript software engineer. Web technology enthusiast, learning to make better web applications one day at a time. Frontend engineer @theflutterwave.