How to Sync Your React App with the System Color Scheme
A Dark Mode Implementation Guide for React
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 Native0.62
, on the other hand, ships with theAppearance
API and theuseColorScheme
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.
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.
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 themelight
— User prefers a light color themeno-preference
— User hasn’t indicated any preference
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 theCOLOR_SCHEMES
list, then theDEFAULT_TARGET_COLOR_SCHEME
value is returned instead.getCurrentColorScheme
— This returns an object with itsquery
property set to aMediaQueryList
object that matches the current user preferred color scheme, and itsscheme
property set to the name of the current color scheme as it appears on theCOLOR_SCHEMES
list. Notice that the function caches theMediaQueryList
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) andcolorScheme
(holds the current color scheme object returned from a call to thegetCurrentColorScheme
function). - Resolve
targetScheme
by calling theresolveTargetColorScheme
function with what was passed to the hook as the target color scheme. Notice the use of theuseMemo()
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 thegetCurrentColorScheme
function and initializes thecolorScheme
ref object with the returned object. It then sets the initialscheme
state to the value of the returned object’sscheme
property. - The
useEffect()
hook is used to initialize theisMounted
ref object totrue
when the component is mounted and also to set up achange
listener on theMediaQueryList
object of the currentcolorScheme
ref object. The cleanup function setsisMounted
tofalse
and removes the previously setchange
listener. Notice that theuseEffect()
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 thechange
listener from the currentMediaQueryList
object, updates thecolorScheme
ref with the new object returned fromgetCurrentColorScheme
, updates thescheme
state if the component is still mounted, and finally sets up achange
listener on the newMediaQueryList
object of the currentcolorScheme
ref object. - Finally, the hook returns a boolean —
true
if the currentscheme
state matches the color scheme resolved astargetScheme
, andfalse
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 theAppearance
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?”
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.