Zain Sajjad Head of Product Experience at Peekaboo Guru. In love with mobile machine learning, React, React Native, and UI designing.

Dark mode in React: An in-depth guide

5 min read 1421

As we move towards a better and more accessible user experience on the web with every passing day, dark mode has become a mainstream feature for web apps. When it comes to the development of dark mode, it’s more than just adding a simple toggle button and managing the CSS variable. Here we will discuss creating a complete dark mode experience in React app.

Here is what we will cover:

  • Using system settings
  • Managing themes using CSS variables
  • Implementing the color scheme toggle using react-toggle
  • Storing user-preferred mode using use-persisted-state
  • Selecting color combination suited for a wider audience
  • Handling images in dark mode

You can find the demo application and its code on Github.

Using system settings

No one wants to hurt a user’s eyes when they land on their website! It’s best practice to set the app’s theme according to the device’s settings. CSS media queries, generally known for usage with responsive design, also help us check for other device characteristics.

Here we will use the prefers-color-scheme that gives us dark, light, or no-preference based on the device’s selected color scheme.

Even in its simplest form, this alone can help us adding a dark mode to web apps:

@media (prefers-color-scheme: dark) {
  background-color: #1F2023
  color: #DADADA
}

Like any other media query, styles in this block will be applied when the device’s color scheme is set to dark. Placing it in some component styles will look like this:

import { styled } from '@linaria/react';

const Text = styled.p`
  margin: 12px;
  color: #1F2023;
  background-color: #FAFAFA;
  @media (prefers-color-scheme: dark) {
    background-color: #1F2023
    color: #DADADA
  }
`;

This is good to begin with, but one cannot keep adding these styles in each component. In this case, CSS variables are the answer.

Managing themes using CSS variables

CSS variables are one tool that was missing from web styling for a long, long time. Now that they are available with all browsers, CSS is more fun and less of a pain.

CSS variables are scoped to the element(s) on which they are declared and participate in the cascade (i.e., elements are override values for children).

We made a custom demo for .
No really. Click here to check it out.

We can leverage CSS variables to define themes of our application. Here’s a small snippet to recall how CSS variables are declared:

body {
  --color-background: #FAFAFA;
  --color-foreground: #1F2023;
}

To use these variables in our components, we will swap color codes with variables:

const Text = styled.p`
  margin: 12px;
  color: var(--color-foreground);
  background-color: var(--color-background);
`;

Now that our colors are defined via CSS variable we can change values on top of our HTML tree (e.g., <body>) and the reflection can be seen on all elements:

body {
  --color-background: #FAFAFA;
  --color-foreground: #1F2023;

  @media (prefers-color-scheme: dark) {
    --color-background: #1F2023;
    --color-foreground: #EFEFEF;
  }
}

Implementing a color scheme toggle

At this point, we have the simplest solution that works based on the device’s preferences. Now we have to scale it for devices that do not natively support dark mode.

In this case, we have to make it easy for users to set their preferences for our web app. I opted for react-toggle to make our solution better at a11y along with good aesthetics. This can be achieved via simple button and useState.

Gif of a toggle slider turning dark mode off and on

Here is how our toggle component looks:

import React, { useState } from "react";
import Toggle from "react-toggle";

export const DarkModeToggle: React.FC = () => {
  const [isDark, setIsDark] = useState<boolean>(true);

  return (
    <Toggle
      className="dark-mode-toggle"
      checked={isDark}
      onChange={({ target }) => setIsDark(target.checked)}
      icons={{ checked: "🌙", unchecked: "🔆" }}
      aria-label="Dark mode toggle"
    />
  );
};

This component will hold the user’s selected mode, but what about the default value? Our CSS solution respected the device’s preference. To pull media query results in our react component, we will leverage react-responsive. Under the hood, it uses Window.matchMedia() and re-renders our component when the query’s output is changed.

An updated version of the button looks like the following:

import React, { useState } from "react";
import Toggle from "react-toggle";

export const DarkModeToggle: React.FC = () => {
  const [isDark, setIsDark] = useState<boolean>(true);

  const systemPrefersDark = useMediaQuery(
    {
      query: '(prefers-color-scheme: dark)',
    },
    undefined,
    (isSystemDark: boolean) => setIsDark(isSystemDark)
  );

  return (
    <Toggle
      className="dark-mode-toggle"
      checked={isDark}
      onChange={({ target }) => setIsDark(target.checked)}
      icons={{ checked: "🌙", unchecked: "🔆" }}
      aria-label="Dark mode toggle"
    />
  );
};

The useMediaQuery hook takes a query, initial value, and an onChange handler that is fired whenever the query’s output is changed.

Emulating dark mode in browsers

Now our component will be in sync with the device’s preferences, and its value will be updated accordingly. But how can we test if it’s done right?

Thanks to developer-friendly browsers, we can emulate device preferences from browser inspectors; here is how it looks in Firefox:

Gif of dark mode toggling on and off in Firefox

It’s time to connect our toggle component’s state change to CSS. This can be done with several different techniques. Here, we have opted for the simplest one: adding a class on the root HTML tag and letting CSS variables do the rest.

To accommodate this, we will update the CSS of our body tag:

body {
  --color-background: #FAFAFA;
  --color-foreground: #1F2023;

  &.dark {
    --color-background: #1F2023;
    --color-foreground: #EFEFEF;
  }
}

Here is our effect to add and remove classes based on state:

...
useEffect(() => {
  if (isDark) {
    document.body.classList.add('dark');
  } else {
    document.body.classList.remove('dark');
  }
}, [isDark]); 
...

Storing the user’s preferred mode using use-persisted-state

If we keep the user’s preferred color scheme in the component’s state, it might become problematic, because we won’t be able to get the values outside of this component. Also, it will vanish as soon as our app is mounted again. Both problems can be solved in different ways, including with React Context or any other state management approach.

One other solution is to use the use-persisted-state. This will help us fulfill all requirements. It persists the state with localStorage and keeps the state in sync when the app is open in different tabs of a browser.

We can now move our dark mode state in a custom hook that encapsulates all logic related to media query and persistent state. Here is how this hook should look:

import { useEffect, useMemo } from 'react';
import { useMediaQuery } from 'react-responsive';
import createPersistedState from 'use-persisted-state';

const useColorSchemeState = createPersistedState('colorScheme');

export function useColorScheme(): {
  isDark: boolean;
  setIsDark: (value: boolean) => void;
} {
  const systemPrefersDark = useMediaQuery(
    {
      query: '(prefers-color-scheme: dark)',
    },
    undefined,
  );
  const [isDark, setIsDark] = useColorSchemeState<boolean>();
  const value = useMemo(() => isDark === undefined ? !!systemPrefersDark : isDark,
    [isDark, systemPrefersDark])
  useEffect(() => {
    if (value) {
      document.body.classList.add('dark');
    } else {
      document.body.classList.remove('dark');
    }
  }, [value]);
  return {
    isDark: value,
    setIsDark,
  };
}

The toggle button component will be much simpler now:

/**
 *
 * ColorSchemeToggle
 *
 */
import Toggle from 'react-toggle';
import { useColorScheme } from 'platform/ColorScheme';
import { DarkToggle } from './Styled';

const ColorSchemeToggle: React.FC = () => {
  const { value, setValue } = useColorScheme();
  return (
    <DarkToggle>
      <Toggle
        checked={value === 'dark'}
        onChange={(event) => setValue(event.target.checked ? 'dark' : 'light')}
        icons={{ checked: '🌙', unchecked: '🔆' }}
        aria-label="Dark mode"
      />
    </DarkToggle>
  );
};

export default ColorSchemeToggle;

Selecting dark theme colors

While dark mode itself can be considered an accessibility feature, we should focus on keeping this feature accessible for a wider audience.

We leveraged react-toggle in our demo to ensure the button used for changing color scheme follows all a11y standards. Another important part is the selection of background and foreground colors in both dark and light mode. In my opinion, colors.review is a great tool to test the contrast ratio between colors; having a AAA grade makes our apps easier to navigate and more comfortable to look at.

Gif of a React app with dark mode toggling under a photo of flowers

Handling images in dark mode

For better aesthetics, we usually have pages with bright images. In dark mode, bright images might become a discomfort for users.

Several techniques can avoid these issues, including using different images for both modes and changing colors in SVG images. One way is to use CSS filters on all image elements; this will help lower eye strain when bright images appear on the user’s canvas.

To enable this, our global styles will look like the following:

body {
  --color-background: #FAFAFA;
  --color-foreground: #1F2023;

  --image-grayscale: 0;
  --image-opacity: 100%;

  &.dark {
    --color-background: #1F2023;
    --color-foreground: #EFEFEF;

    --image-grayscale: 50%;
    --image-opacity: 90%;
  }
}

img,
video {
  filter: grayscale(var(--image-grayscale)) opacity(var(--image-opacity));
}

Conclusion

Accessibility in web apps today is not just a utility. Rather, it is one of the basic requirements. Dark mode in this respect, when implemented, should be considered as a full feature that requires much attention, just like any other critical feature.

In this article, we established a way to implement dark mode to its full extent; let me know in the comments if you feel I have missed anything.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Zain Sajjad Head of Product Experience at Peekaboo Guru. In love with mobile machine learning, React, React Native, and UI designing.

Leave a Reply