Share to Mastodon
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> | |
); | |
} |
"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> | |
); | |
} |
/* 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.