Thoughts on software development, technology, and other random philosophical matters.
Daryl Wright /     (14 mins)

Why implement dark/light mode switching?

Increasingly more websites provide both dark and light modes for their users, and for good reason. Dark mode websites and user interfaces emit less light from the screen. Less light being emitted reduces eye strain, and as a result, increases productivity. Many people claim that using dark mode at night helps them get a better night's rest.

There is less disorientation when looking away from a dark screen compared to a bright one. This likely because of how the irises in your eyes work. The iris is actually a combination of a radial muscle and a sphincter muscle that causes the pupils dilate and constrict respectively. When looking at different objects of similar luminosity, these muscles don't move very much. Conversely, constantly looking between objects of varying luminosities makes the radial and sphincter muscles of the iris work much harder, causing strain. In extreme cases, you can have temporarily impaired vision. You will have experienced this if you've ever come from outside on a sunny day into a relatively dark room.

Obstacles to implementing dark mode

Personally, I use dark mode for as many programs and websites as possible, with many, if not most, developers in agreement. Despite the benefits of dark mode, there are enough people that prefer light mode that it would be unreasonable to restrict all user interfaces to dark mode only.

Unfortunately, there are many websites and apps that still, for whatever reason, only exist with one of these modes at all times. There are likely many reasons for this, but the one most obvious to me is the friction involved in implementing both dark and light modes. In most cases, this involves a complete user interface overhaul, whose costs are prohibitive enough to keep an initiative of this nature on the backlog in perpetuity.

One source of this friction is the styling framework of choice. For instance, a website using Bootstrap is less likely to implement a light and dark mode due to the difficulty in implementing multiple themes. Conversely, TailwindCSS has dark mode built in from the ground up. Changing to a theme-friendly style framework is usually the best course of action when implementing multiple themes. The other choice is to constantly fight with your theme-unfriendly framework of choice, spending time on lengthy maintenance that would have been better spent switching to a better framework.

With a theme-friendly framework in place, there must now be a way for a user to switch from light to dark modes and vice-versa. I'll demonstrate a way to do this with React and TailwindCSS. Doing so with alternative JavaScript and CSS frameworks is outside the scope of this post.

Implementing dark/light mode switching

Let's build some components that will drive our theme switching functionality. This post will assume you're already familiar with applying dark mode styles to components with TailwindCSS.

Our theme switching functionality comes in two parts. First is the ThemeProvider that wraps the root element of your application and stores the state of the current theme. Second is the ThemeButton which is used to toggle between light and dark modes.

Building the theme provider

The ThemeProvider component will make use of React's Context API. This will enable us to read the theme state from the provider's stored data anywhere in the application. We'll start by creating our context object with createContext and our ThemeProvider component.

// theme.tsx
import { createContext, ReactNode } from "react";

type ThemeProviderProps = {
  children: ReactNode;
};

export const ThemeContext = createContext(null);

export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
  return (
    <ThemeContext.Provider value={null}>
      {children}
    </ThemeContext.Provider>
  );
}

Now that we have created our ThemeProvider, this is a good time to wrap your app's root element with it. If you're using Gatsby, for example, it might look something like this:

export default function RootElement({ element }): JSX.Element {
  return (
    <ThemeProvider>
      {element}
    </ThemeProvider>
  );
}

For any other web application framework, the principle is the same. Just wrap the root element of your app with the ThemeProvider.

Currently, the ThemeProvider only provides a null value throughout the application, which isn't terribly useful. We need a way to store and manipulate data on which theme is active. We can accomplish this with React's useState hook by storing the value returned by that function.

// theme.tsx
import {
  createContext,
  Dispatch,
  ReactNode,
  SetStateAction,
  useState,
} from "react";

type ThemeProviderProps = {
  children: ReactNode;
};

// Defining the type returned by the `useState` hook to avoid errors/warnings when setting the
// value of the `ThemeContext.Provider` component
type ThemeContextData = [boolean, Dispatch<SetStateAction<boolean>>];

// Calling the generic version of `createContext` where the type is of `ThemeContextData`
export const ThemeContext = createContext<ThemeContextData>([false, () => {}]);

export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
  const [darkMode, setDarkMode] = useState(false);
  return (
    <ThemeContext.Provider value={[darkMode, setDarkMode]}>
      {children}
    </ThemeContext.Provider>
  );
}

Now that we've set the value of the ThemeContext.Provider component, we can grab it anywhere in the application with the useContext hook. We'll revisit this later. For now, while we're able to manipulate state with our ThemeProvider, we have not yet implemented switching between light and dark modes. We'll use the useEffect hook to accomplish this only when the darkMode value changes.

// theme.tsx
import {
  createContext,
  Dispatch,
  ReactNode,
  SetStateAction,
  useEffect,
  useState,
} from "react";

type ThemeProviderProps = {
  children: ReactNode;
};

type ThemeContextData = [boolean, Dispatch<SetStateAction<boolean>>];

export const ThemeContext = createContext<ThemeContextData>([false, () => {}]);

export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
  const [darkMode, setDarkMode] = useState(false);

  useEffect(() => {
    const html = document.querySelector<HTMLElement>(`html`);
    if (darkMode) {
      html?.classList.add(`dark`);
    } else {
      html?.classList.remove(`dark`);
    }
  }, [darkMode]);

  return (
    <ThemeContext.Provider value={[darkMode, setDarkMode]}>
      {children}
    </ThemeContext.Provider>
  );
}

Dark mode in TailwindCSS gets applied when the <html> tag has the dark CSS class. Light mode is activated when the dark class is not present. We only need to switch between these modes when the darkMode variable changes, which is why it is listed as a dependency in the useEffect function.

We could consider this part completed at this point. However, there are a few problems with this implementation. Firstly, if you're using a linter during development, you will probably have noticed the following error in the ThemeContext.Provider component:

ESLint: The array passed as the value prop to the Context provider (at line 65) changes every render.
To fix this consider wrapping it in a useMemo hook.(react/jsx-no-constructed-context-values)

Like the warning says the value passed to the ThemeContext.Provider component, [darkMode, setDarkMode], will be updated on every render. This is undesirable since we only need to change this value when we change the value of darkMode. We can use the useMemo hook to cache the value returned from useState until the darkMode variable changes. While we're at it, we can also use the useCallback hook to cache the value of the setDarkMode function, so it doesn't have to be redefined on each render.

// theme.tsx
import {
  createContext,
  Dispatch,
  ReactNode,
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from "react";

type ThemeProviderProps = {
  children: ReactNode;
};

type ThemeContextData = [boolean, Dispatch<SetStateAction<boolean>>];

export const ThemeContext = createContext<ThemeContextData>([false, () => {}]);

export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
  const [darkMode, setDarkMode] = useState(false);
  const setDarkModeCallback = useCallback(setDarkMode, []);
  const darkModeMemo = useMemo<ThemeContextData>(
    () => ([darkMode, setDarkModeCallback]),
    [darkMode],
  );

  useEffect(() => {
    const html = document.querySelector<HTMLElement>(`html`);
    if (darkMode) {
      html?.classList.add(`dark`);
    } else {
      html?.classList.remove(`dark`);
    }
  }, [darkMode]);

  return (
    <ThemeContext.Provider value={darkModeMemo}>
      {children}
    </ThemeContext.Provider>
  );
}

Our ThemeProvider is now complete, but we can add one finishing touch. We can default the theme to the user's system colour scheme and let them choose their preferred mode afterward. If the above code is good enough for your use case, you can skip to the next section.

Using the system default colour scheme

import {
  createContext,
  Dispatch,
  ReactNode,
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from "react";

type ThemeProviderProps = {
  children: ReactNode;
};

type ThemeContextData = [boolean, Dispatch<SetStateAction<boolean>>];

export const ThemeContext = createContext<ThemeContextData>([false, () => {}]);

export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
  const [darkMode, setDarkMode] = useState(false);
  const setDarkModeCallback = useCallback(setDarkMode, []);
  const darkModeMemo = useMemo<ThemeContextData>(
    () => ([darkMode, setDarkModeCallback]),
    [darkMode],
  );

  useEffect(() => {
    const isSystemSchemeDark = window.matchMedia(`(prefers-color-scheme: dark)`).matches;
    setDarkMode(isSystemSchemeDark);
  }, []);

  useEffect(() => {
    const html = document.querySelector<HTMLElement>(`html`);
    if (darkMode) {
      html?.classList.add(`dark`);
    } else {
      html?.classList.remove(`dark`);
    }
  }, [darkMode]);

  return (
    <ThemeContext.Provider value={darkModeMemo}>
      {children}
    </ThemeContext.Provider>
  );
}

I introduced a defect here. While the theme gets set to the user's preferences, it does so on every page load. For instance, if a user's system color scheme is set to 'dark', switching to light mode will not persist across page loads. We will need to store the user's preferences in a cookie, instead of in memory. This allows the theme to be set based on the user's system settings only once, then with cookie data afterward. I'll be using the js-cookie library to get and set cookies for the sake of simplicity.

import {
  createContext,
  Dispatch,
  ReactNode,
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from "react";
import Cookies from "js-cookie";

type ThemeProviderProps = {
  children: ReactNode;
};

type ThemeContextData = [boolean, Dispatch<SetStateAction<boolean>>];

export const ThemeContext = createContext<ThemeContextData>([false, () => {}]);

const themeCookieName = `is_dark_mode`;

export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
  const isDarkMode = Cookies.get(themeCookieName);
  const [darkMode, setDarkMode] = useState(isDarkMode === `true`);
  const setDarkModeCallback = useCallback(setDarkMode, []);
  const darkModeMemo = useMemo<ThemeContextData>(
    () => ([darkMode, setDarkModeCallback]),
    [darkMode],
  );

  useEffect(() => {
    const isSystemSchemeDark = window.matchMedia(`(prefers-color-scheme: dark)`).matches;
    const cookieValue = Cookies.get(themeCookieName) ?? ``;
    const isCookieUnset = cookieValue === ``;
    if (isSystemSchemeDark && isCookieUnset) {
      setDarkMode(isSystemSchemeDark);
    }
  }, []);

  useEffect(() => {
    Cookies.set(themeCookieName, JSON.stringify(darkMode), { expires: 365 });
    const html = document.querySelector<HTMLElement>(`html`);
    if (darkMode) {
      html?.classList.add(`dark`);
    } else {
      html?.classList.remove(`dark`);
    }
  }, [darkMode]);

  return (
    <ThemeContext.Provider value={darkModeMemo}>
      {children}
    </ThemeContext.Provider>
  );
}

Building the theme button

The ThemeButton component will enable us to switch between light and dark modes when pressed. It will use the useContext hook with the ThemeContext we created earlier to access the data we need to set the active theme. We'll start by creating a simple button.

// theme-button.tsx
import { ComponentPropsWithoutRef } from "react";

// Creating a component type identical to an HTMLButtonElement, but without `children` which
// we will later implement manually
type ThemeButtonProps = Omit<ComponentPropsWithoutRef<`button`>, `children`>;

export default function ThemeButton({...props}: ThemeButtonProps): JSX.Element {
  return (
    <button type="button" {...props} />
  );
}

Next, let's import the ThemeContext from earlier and also the useContext hook from React core. We'll use them both to destructure the darkMode flag and the setDarkMode function from the context.

// theme-button.tsx
import { ComponentPropsWithoutRef, useContext } from "react";
import { ThemeContext } from "./theme";

type ThemeButtonProps = Omit<ComponentPropsWithoutRef<`button`>, `children`>;

export default function ThemeButton({...props}: ThemeButtonProps): JSX.Element {
  const [darkMode, setDarkMode] = useContext(ThemeContext);
  return (
    <button type="button" {...props} />
  );
}

Let's complete the ThemeButton by toggling the value of darkMode whenever it is clicked. We'll also give the button an icon that changes depending on the current theme.

// theme-button.tsx
import { ComponentPropsWithoutRef, useContext } from "react";
import { ThemeContext } from "./theme";

type ThemeButtonProps = Omit<ComponentPropsWithoutRef<`button`>, `children`>;

export default function ThemeButton({...props}: ThemeButtonProps): JSX.Element {
  const [darkMode, setDarkMode] = useContext(ThemeContext);
  return (
    <button
      type="button"
      onClick={() => setDarkMode(!darkMode)}
      {...props}
    >
      { darkMode ? `🌙` : `☀️`}
    </button>
  );
}

You can now render the ThemeButton anywhere in your application to allow users to switch between light and dark modes. Below is an example of its usage:

// Typical location for a dark/light mode button is in the navbar of the application
export default function SomeComponent(): JSX.Element {
  return (
    <nav>
      <ul>
        <li><a href="#">Nav Item 1</a></li>
        <li><a href="#">Nav Item 2</a></li>
        <li><a href="#">Nav Item 3</a></li>
        <li>
          {/*
            Since the ThemeButton inherits its props from the HTMLButtonElement type, we can
            use it just like any other button, including customizing styles via the `className`
            prop.
          */}
          <ThemeButton className="decoration-0 border-0 bg-transparent" />
        </li>
      </ul>
    </nav>
  );
}

I've provided the full solution below for your convenience. While this solution focuses on an application using TailwindCSS as the styling framework, it can be modified to work with any framework with a bit of tweaking. If you would like to see a live demo of this solution, it's right here in this very website. Just scroll to the top of the page and find the moon or sun icon and click away. Enjoy!

// theme.tsx
import {
  createContext,
  Dispatch,
  ReactNode,
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from "react";
import Cookies from "js-cookie";

type ThemeProviderProps = {
  children: ReactNode;
};

type ThemeContextData = [boolean, Dispatch<SetStateAction<boolean>>];

export const ThemeContext = createContext<ThemeContextData>([false, () => {}]);

const themeCookieName = `is_dark_mode`;

export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
  const isDarkMode = Cookies.get(themeCookieName);
  const [darkMode, setDarkMode] = useState(isDarkMode === `true`);
  const setDarkModeCallback = useCallback(setDarkMode, []);
  const darkModeMemo = useMemo<ThemeContextData>(
    () => ([darkMode, setDarkModeCallback]),
    [darkMode],
  );

  useEffect(() => {
    const isSystemSchemeDark = window.matchMedia(`(prefers-color-scheme: dark)`).matches;
    const cookieValue = Cookies.get(themeCookieName) ?? ``;
    const isCookieUnset = cookieValue === ``;
    if (isSystemSchemeDark && isCookieUnset) {
      setDarkMode(isSystemSchemeDark);
    }
  }, []);

  useEffect(() => {
    Cookies.set(themeCookieName, JSON.stringify(darkMode), { expires: 365 });
    const html = document.querySelector<HTMLElement>(`html`);
    if (darkMode) {
      html?.classList.add(`dark`);
    } else {
      html?.classList.remove(`dark`);
    }
  }, [darkMode]);

  return (
    <ThemeContext.Provider value={darkModeMemo}>
      {children}
    </ThemeContext.Provider>
  );
}

// theme-button.tsx
import { ComponentPropsWithoutRef, useContext } from "react";
import { ThemeContext } from "./theme";

type ThemeButtonProps = Omit<ComponentPropsWithoutRef<`button`>, `children`>;

export default function ThemeButton({...props}: ThemeButtonProps): JSX.Element {
  const [darkMode, setDarkMode] = useContext(ThemeContext);
  return (
    <button
      type="button"
      onClick={() => setDarkMode(!darkMode)}
      {...props}
    >
      { darkMode ? `🌙` : `☀️`}
    </button>
  );
}

Update: Issues with this implementation

I originally designed the above theme solution within a Gatsby app (specifically, this website before converting it to Next.js). During testing, I found that there was a delay in applying the dark theme. Each page navigation would display the light mode briefly before switching to dark mode. This is unacceptable in 2023.

I can't guarantee that the above theming implementation will function correctly in other frameworks, so it's usefulness for now is downgraded to a learning exercise. Read on for a theming solution that works in Next.js.

Next.js ThemeButton

I found the above theme button implementation did not work for me in a Next.js environment. In particular, apps using the new App Router API. I ended up using the next-themes package from GitHub user @wits, forked from the original creator @pacocoursey. See the example below and note the TODO comment in theme-button.tsx.

import {ReactNode, ReactElement} from "react";
import RootComponent from "@/components/root";
export default function RootLayout({ children }: { children: ReactNode }): ReactElement {
return (
<html suppressHydrationWarning>
<body>
<RootComponent>
{children}
</RootComponent>
</body>
</html>
);
}
view raw layout.tsx hosted with ❤ by GitHub
"use client";
import {ReactNode, ReactElement} from "react";
import {ThemeProvider} from "@wits/next-themes";
type RootProps = {
children: ReactNode;
}
export default function RootComponent({ children }: RootProps): ReactElement {
return (
<ThemeProvider attribute="class" themes={[`light`, `dark`]} enableSystem>
{children}
</ThemeProvider>
);
}
view raw root.tsx hosted with ❤ by GitHub
/* eslint-disable react/jsx-props-no-spreading */
import {ComponentPropsWithoutRef, ReactElement, useEffect, useState} from "react";
/*
TODO: See this GitHub issue: https://github.com/pacocoursey/next-themes/issues/161
The original next-themes package does not work with the App Router API in Next.js.
The package by user @wits enables this as indicated in the above issue. Use this
package until the original by @pacocoursey is updated to work with App Router.
*/
import {useTheme} from "@wits/next-themes";
type ThemeButtonProps = Omit<ComponentPropsWithoutRef<`button`>, `children`>;
export default function ThemeButton({ className, ...props }: ThemeButtonProps): ReactElement | null {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
if (theme === `system`) {
const isSystemThemeDark = window.matchMedia(`(prefers-color-scheme: dark)`).matches;
setTheme(isSystemThemeDark ? `dark` : `light`);
}
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<button
type="button"
onClick={() => setTheme(theme === `dark` ? `light` : `dark`)}
{...props}
>
<span>
{theme === `dark` ? `🌙` : `☀️`}
</span>
</button>
);
}

Theming is functional and without flashes on route changes with the above implementation. One thing does not sit well with me in the above code. Note the following.

const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}

Without this snippet, the following errors appear in the client console.

Uncaught Error: Minified React error #425; ; visit https://reactjs.org/docs/error-decoder.html?invariant=425 for the full message...
Uncaught Error: Minified React error #423; ; visit https://reactjs.org/docs/error-decoder.html?invariant=423 for the full message...

The underlying message behind one of these errors is Text content does not match server-rendered HTML.. This happens because the theme information can only be read from the client, due to using local storage. On the server, there is no local storage, so the theme setting is empty by default (<html class="" style="">).When the page loads, the theme setting is initialized immediately, which differs from the empty setting on the server (<html class="dark" style="color-scheme: dark;">).

A simple solution to this problem is to not render the component on the server. That's the gist of what the above snippet does. It's ugly, but it works. I suspect this might be an issue with the next-themes package, but that discovery is outside the scope of this current writing.

Updated July 11, 2023
Daryl Wright's picture
About Daryl Wright

Daryl is an experienced technology advisor who specializes in creating innovative software solutions and solving novel problems. He has spent over a decade providing technical expertise in various industries and is well versed in a wide variety of programming languages, tools, and frameworks. Daryl is the owner of Golden Path Technologies, a proud father, husband, and dog parent.

22 posts Miramichi, New Brunswick, Canada https://goldenpath.ca Github Reddit Mastodon Bluesky