Skip to content
Colin Wren
Twitter

Adding Dark Mode to my Expo based React Native app

JavaScript, Software Development4 min read

Last week I added a new date picker component to my React Native app — JiffyCV. This date picker looked amazing in my component library and I was really proud of how it was working.

Then, I updated my app pull in the new version of the component library, added the new component, took it for a test run on my iPhone and was presented with a blank box where the date picker was meant to be.

After a couple of new releases of changing the code in a desperate bid to get it to work and failing I finally found the answer to my problems — The date picker was there, it’s just that as my phone was in dark mode the text was white and my picker just happened to be on a white background too.

I felt like a complete idiot, having wasted time thinking it was a code fix but then I started to think — If my phone is on dark mode then how come my component library didn’t have the same issue? It turns out that storybook was setting the theme to light mode regardless of the phone’s setting.

I then came to realise that if I was having these issues then it’s likely that others will have similar issues and I should probably look to support both dark and light modes.

Designing for Dark Mode

When designing the app I had used Figma to build prototypes in order to carry out some user testing and validate the idea before writing any code.

As I had all the screens for the app already laid out from my initial designs I was able to use Figma’s palette swapping functionality to create copies of the designs and change the colours used for the dark mode colours.

This made things really easy to work with and sped up iterating over designs massively, and there was many iterations because it turns out designing for Dark Mode isn’t that easy.

Dark Mode isn’t just a case of inverting the light mode palette, you still need to convey the correct hierarchy of information and a simple white -> black inversion would flip this hierarchy on it’s head.

Additionally, if you have bright colours you may find they become incredibly jarring when used in the Dark Mode context, I had to create a set of muted tones for my accent colour as the brightness was just too much in Dark Mode.

Compare the three images below; the left is just an inversion of light mode, the middle is with making sure that the hierarchy of the screen elements is maintained and the right is the light mode version.

jiffycv screens showing the difference a black vs light grey background makes
As you can see the middle one maintains the visual hierarchy of light mode making the UI far easier to understand

In order to create a sense of depth in a design that’s already incredibly dark I used ‘proper’ black (#000000) for the darkest colour instead of the slightly lighter shade I had been using in light mode.

This allowed me use that slightly lighter shade as the base colour for the rest of the design (essentially becoming the dark mode equivalent of white in my designs).

Another important factor is contrast, because of the shift in background colour you may find that any accent colours that were compliant with WCAG are no longer contrasted enough to pass. This combined with the jarring effect mentioned earlier means it can be hard to adapt designs to dark mode.

Bringing Dark Mode to your Expo based React Native app

Expo makes it pretty simple to handle how your app responds to the user’s colour settings within it’s app.json file, using the userInterfaceStyle property which can be set to automatic to honour the user’s current colour scheme choice or light and dark to force the app into one of the colour schemes.

You can also set the userInterfaceStyle property inside the ios and android objects to manage these at a platform level. For my app I opted for automatic across both.

To detect the colour scheme within your app you’ll need to add the react-native-appearance package to your project, as this gives us the AppearanceProvider context we’ll wrap our app in and the useColorScheme hook we’ll use at the component level to ensure the correct stylesheet is used.

Here’s an example of using the AppearanceProvider to wrap the app.

1import React from 'react';
2import { AppearanceProvider } from 'react-native-appearance';
3import MainApp from './src/app';
4
5export default function App() {
6 return (
7 <AppearanceProvider>
8 <MainApp />
9 </AppearanceProvider>
10 );
11}
In side MainApp we can use the useColorScheme hook to get the colour scheme value

Here’s an example of using theuseColorScheme hook in a component, the hook returns 'dark' if the user is using dark mode, so a check for this can be used to load in a dark mode style sheet.

1import React from 'react';
2import { View, Text } from 'react-native';
3import { useColorScheme } from 'react-native-appearance';
4
5export function ExampleComponent() {
6 const colourScheme = useColorScheme();
7 const isDarkMode = colourScheme === 'dark';
8 const textStyle = {
9 color: isDarkMode ? 'white' : 'black',
10 }
11 const viewStyle = {
12 backgroundColor: isDarkMode ? 'black' : 'white',
13 }
14 return (
15 <View style={viewStyle}>
16 <Text style={textStyle}>This is some text</Text>
17 </View>
18 )
19}
useColorScheme returns ‘dark’ if the use is using dark mode, ‘light’ if using light mode

In order to have both light and dark mode styles available in my components I took the existing stylesheet used in light mode and extracted the style object into a const I called baseStyles .

I then created a darkModeOverrides const that copied and amended the baseStyles before creating stylesheet instances for light and dark modes based off the respective objects.

I then used the output of useColorScheme to use the correct stylesheet within my components.

This approach allowed me to quickly implement dark mode by only focusing on the things I needed to change, but if I was to refactor I’d most likely remove the structural styles from the colour ones to make it even cleaner.

1import React from 'react';
2import { View, Text, StyleSheet } from 'react-native';
3import { useColorScheme } from 'react-native-appearance';
4
5const baseStyles = {
6 text: {
7 color: 'black',
8 },
9 view: {
10 padding: 20,
11 backgroundColor: 'white',
12 },
13}
14
15const darkModeOverrides = {
16 ...baseStyles,
17 text: {
18 color: 'white',
19 },
20 view: {
21 ...baseStyles.view,
22 backgroundColor: 'black',
23 },
24}
25
26const lightModeStyle = StyleSheet.create(baseStyles);
27const darkModeStyle = StyleSheet.create(darkModeOverrides);
28
29export function ExampleComponent() {
30 const colourScheme = useColorScheme();
31 const isDarkMode = colourScheme === 'dark';
32 const style = isDarkMode ? darkModeStyle : lightModeStyle;
33 return (
34 <View style={style.view}>
35 <Text style={style.text}>This is some text</Text>
36 </View>
37 )
38}
This is a simple example of how I converted my existing app, as you can see I can keep the padding on the view by only updating the values I need to change for dark mode

In order to test my UI under light and dark mode I created a simple mock for react-native-appearance that allows me to return ‘dark’ when I need to test the UI in dark mode.

1import renderer from 'react-test-renderer';
2import ExampleComponent from '../ExampleComponent';
3
4const mockColorScheme = jest.fn();
5jest.mock('react-native-appearance', () => ({
6 ueColorScheme: mockColorScheme,
7}));
8
9describe('ExampleComponent', () => {
10 it('Renders in light mode', () => {
11 const tree = renderer.create(<ExampleComponent />);
12 expect(tree).toMatchSnapshot();
13 });
14 it('Renders in dark mode', () => {
15 mockColorScheme.mockImplementationOnce(() => 'dark');
16 const tree = renderer.create(<ExampleComponent />);
17 expect(tree).toMatchSnapshot();
18 });
19});
As the checks we’re doing in the component are for the value to be ‘dark’ we can just use jest.fn() until we need to set the return value

Gotcha — React Native Storybook

I built my component library using the React Native version of Storybook that Expo provides in one of their starter projects.

I ended up having to upgrade the version of @storybook/.react-native to 5.3.23 in order to have the userInterfaceStyle setting honoured by storybook.

I also had to make changes to the App.tsx file in order to wrap the main Storybook view in the AppearanceProvider . A gist of how that looks can be found below.

1import React from 'react';
2import { configure, getStorybookUI } from "@storybook/react-native";
3import { AppearanceProvider, Appearance } from "react-native-appearance";
4
5configure(() => {
6 // Since require.context doesn't exist in metro bundler world, we have to
7 // manually import files ending in *.stories.js
8 require("./stories");
9}, module);
10
11const darkMode = {
12 backgroundColor: 'black',
13 headerTextColor: 'white',
14 labelColor: 'white',
15 borderColor: 'black',
16 previewBorderColor: 'black',
17 buttonTextColor: 'red',
18 buttonActiveTextColor: 'grey',
19}
20
21
22const AppEntry = () => {
23 let colorScheme = Appearance.getColorScheme();
24 const StorybookUI = getStorybookUI({
25 // Pass AsyncStorage below if you want Storybook to open your
26 // last visited story after you close and re-open your app
27 asyncStorage: null,
28 theme: colorScheme === 'dark' ? darkMode : {}
29 });
30 return (
31 <AppearanceProvider>
32 <StorybookUI />
33 </AppearanceProvider>
34 )
35}
36
37export default AppEntry;
Wrapping the storybook UI in appearance provider allows all components in the component library to use the useColorScheme hook

It’s worth noting that while you can provide a theme to Storybook it will only style it’s elements. The base view of the application still has a white background but these changes were enough at least for me to test my components while creating them.

Summary

Unless you’re using components that aren’t colour scheme aware you can probably build an app without having to concern yourself with building an adaptive UI.

However, if like me you use a native date picker on iOS you may find yourself with a UI that doesn’t work unless you decide to force a particular mode or build something a little more adaptive.

If you do decide to build an adaptive UI then it’s important to understand and test how your UI will work in dark mode instead of just inverting your existing colour scheme.

Once you’re happy with how your UI will work then react-native-appearance will help you detect the colour scheme the user is using and allow you to build adaptive UI components.