Nathan Smith
  • Projects
  • About
  • Contact

usePrefersDarkTheme: a theming hook for React

July 24, 2020, 9:01 PMReact

Every site needs a dark theme, including this one. Having a dark theme is easy, but if an app or site's theme doesn't follow the system theme it can lead to blinding the user with a white background until they find the toggle. This hook (and some related code to store the theme state) defaults your theme to the system theme.

Though it's not standardized yet, all modern browsers and operating systems support a CSS media query to determine the preferred color theme:

@media (prefers-color-scheme: dark) { ... }
@media (prefers-color-scheme: light) { ... }
@media (prefers-color-scheme: no-preference) { ... }

I don't particularly care if the user prefers a light theme since that's my default, but this example can be adapted however you want. It would be easy enough to add media change event listeners and keep the preferred value in state, but it's even simpler with react-media. Here's the entire hook:

import { useMedia } from 'react-media';

export function usePrefersDarkTheme() {
  return useMedia({ query: '(prefers-color-scheme: dark)' });
}

Yep, it's a one-liner. Combine this with a context to keep your theme and store the selected theme in localStorage and you have an easy, workable solution for theming:

import React from 'react';
import { darkTheme, lightTheme } from '../util/theme';
import { Theme } from '../../types/theme';
import { usePrefersDarkTheme } from '../hooks/usePrefersDarkTheme';

export const ThemeContext = React.createContext();

export const ThemeContextProvider: React.FC = props => {
  const preferDark = usePrefersDarkTheme();
  const savedTheme: string | null = window.localStorage.getItem('theme');
  const startingTheme = React.useMemo(() => {
    if (savedTheme !== null) {
      if (savedTheme === 'dark') {
        return darkTheme;
      } else if (savedTheme === 'light') {
        return lightTheme;
      }
    }
    if (preferDark) {
      return darkTheme;
    }
    return lightTheme;
  }, [preferDark, savedTheme]);
  const [theme, _setTheme] = React.useState<Theme>(startingTheme);

  React.useLayoutEffect(() => {
    _setTheme(startingTheme);
  }, [startingTheme]);

  const toggleTheme = React.useCallback(() => {
    if (theme === darkTheme) {
      localStorage && localStorage.setItem('theme', 'light');
      _setTheme(lightTheme);
    } else {
      _setTheme(darkTheme);
      localStorage && localStorage.setItem('theme', 'dark');
    }
  }, [theme]);

  return <ThemeContext.Provider value={{ theme, toggleTheme }}>{props.children}</ThemeContext.Provider>;
};

Previous: Using Kanka.io and D&D Beyond to Manage a Tabletop Campaign

Next: Dragon of Icespire Peak - Campaign Diary 1