Skip to content
Tutorial

Create a Light and Dark Mode Theme Toggle in React

Toggle between light and dark themes using the user's system settings as the default.
Comments
Summary: Creating a light/dark toggle for your site is an easy to add UX and accessibility feature. Use custom CSS properties to create two versions of a theme and CSS media queries to default to the user's preference for light and dark mode in their system preferences. Then, add a simple React component that alternates between the two themes.

Why make a theme toggle

A frequent web design mistake that's easy to make is to design for your own computer. This often means designing for your screen size, or developing for your preferred browser, but what about designing for light v.s. dark mode?

Light versus dark mode isn't just a personal preference, for some, it can be an accessibility issue, and may even be a matter of ocular health (Dark Mode vs. Light Mode: Which Is Better?.) As more sites are adding light/dark mode toggle buttons I thought I would do the same. After searching the internet for ways to implement one react, I found that the solutions out there, from the CSS perspective, were either inefficient or not robust.

Here is how to create two alternate themes for a Gatsby (React) site and a toggle to flip between them. It relies on CSS Custom Properties (Variables) to generate the themes, and duplicate fewer than a dozen lines of code across the whole codebase. It defaults to the user's system settings and uses local storage to remember the user's preference if they change it.

Create multiple themes with CSS variables

Text color in dark modeLink color
Text color in light modeLink color

The core tool that makes theming so simple is CSS Custom properties. CSS variables are not new tech by any means, but only recently does it have a long enough history of browser support that you can feel comfortable using it without expecting that every user be on the most up to date browser (caniuse Custom Properties)

Here is a simplified example of using CSS variables to generate a light and a dark theme while minimizing code duplication. This approach reduces the code duplication to only the variable definitions; applying them at the outermost scope lets them propagate down through the whole project.

At basic level, a simple theme could be set up as follows:

.light-theme {
  --text-color: #111;
  --link-color: #9f0dcd;
  --background-color: #EEE;
}
.dark-theme {
  --text-color: #FFF;
  --link-color: #faf697;
  --background-color: #222;
}
body {
    background-color: var(--background-color);
    color: var(--text-color);
}
a { color: var(--link-color); }

Now, adding the classes .light-theme or .dark-theme to the body will change the colors across the whole site, and changing the between the class names will toggle between the two.

But what about respecting the user's settings? Let's make it better by starting in the user's preferred color mode.

Respecting the user's wishes with media queries CSS offers a native way of detecting the user's light/dark mode systems settings using the prefers-color-scheme media query (caniuse prefers-color-scheme.)

Older browser support for this feature doesn't go back very far so let's make sure we have a fallback.

By applying the light mode variables by default and only changing to dark mode if the user has set their system preferences to dark, we've set up an implicit fallback. (this only applies to the default first load state, the user will still be able to toggle modes on older browsers).

To reduce some code duplication in the next section, let's switch from CSS to SCSS to take advantage of 'mixins.

@mixin lightmode() {
    --text-color: #111;
    --link-color: #9f0dcd;
    --background-color: #EEE;
}
@mixin darkmode() {
    --text-color: #FFF;
    --link-color: #faf697;
    --background-color: #222;
}

Defaulting to the user's preferred theming mode does make the logic of light versus dark mode less clear. I find it easier to think of it as being either default mode or inverted mode. Let's use is_inverted as the class name conditionally applied to the body.

body {
    @include lightmode();
    &.is_inverted {
        @include darkmode();
     }
    @media (prefers-color-scheme: dark) {
        @include darkmode();
        &.is_inverted {
            @include lightmode();
        }
    }

    background-color: var(--background-color);
    color: var(--text-color);
    a { color: var(--link-color); }
}

The default theme is now set to the user's preference, and the is_inverted class always applies the opposite.

User Preference Default Mode Inverted Mode
Light Mode light dark
Dark Mode dark light

Create a toggle component in React

The toggle component itself is simple—it returns a button with an onClick function that can be passed to it from a parent component.

import React from "react"
export default function ThemeToggle({ themeSwitch = f => f }) {
  return (
    <button 
      className="themeToggle"
      type="button"
      onClick={themeSwitch}
      ariaLabel="change theme color"
    />
  )
}

Adding theme support to your React app

Most of the heavy lifting is applied in the site's outermost component. My site uses a Layout component on every page, so that's where I added the following code. This could also be added to the root App component if you have access to it (in Gatsby you don't). Or you could create a wrapper component whose sole purpose is to set the theme. Whichever way you implemented it, the core concepts will be the same.

First, we'll need a ThemeToggle function to pass into our button component. This function will toggle the component state between inverted and not. On my first implementation of this feature, although the toggle worked, the state would reset when visiting new pages. I found a solution for using the browser's local storage to keep track of the user's preference in this CSS Tricks article which I've simplified to serve my use-case.

Now when the function runs (onClick), it will look for a lightMode value in the browser's local storage and reassign it to the opposite value. It will then take that new value and pass it to the React component's state.

The next piece is in the process is the useEffect function which watches for changes in the component's lightMode state and, on change, grabs the value from local storage again and adds or removes the is_inverted class from the document body to match. For a deeper dive, this article provides an expansive overview of useEffect.

Changing the body class changes all of the variable values, and the theme re-renders.

import React, { useState, useEffect } from "react"
import ThemeToggle from '../components/ThemeToggle/ThemeToggle'

export default function Layout({children}) {
  const [lightMode, setLightMode] = useState('default')

  const toggleTheme = () => {
    const localTheme = window.localStorage.getItem('lightMode')
    const savedMode = localTheme === 'inverted' ? 'inverted' : 'default'
    if (savedMode === 'default') {
      window.localStorage.setItem('lightMode', 'inverted')
      setLightMode('inverted')
    } else {
      window.localStorage.setItem('lightMode', 'default')
      setLightMode('default')
    }
  }

  useEffect(() => {
    const localTheme = window.localStorage.getItem('lightMode')
    setLightMode(localTheme)
    if (localTheme === 'inverted') {
      document.body.classList.add('is_inverted')
    }
    else {
      document.body.classList.remove('is_inverted')
    }
  },[lightMode])

  return (
    <div className="layout">
        <ThemeToggle themeSwitch={toggleTheme} />
        {children}
    </div>
  )

}

Rather than reaching outside of the app and changing the body class, another approach would have been to define the variables at the layout component scope and conditionally include the inverted class name at that level. This approach doesn't let you set the actual body color (having the body color match the theme provides a better experience), and it restricts your app design to all components having to be within your layout component (which may be restrictive in some scenarios)

Conclusion - The result

For the received UX benefit of respecting the user's color mode preferences, the time cost of implementing a theme switcher is relatively low. Implementing the toggle is simple—the majority of the work involved is refactoring the CSS to use variables appropriately (and deciding on just the right shades of gray for the dark theme version.)