Properly implement dark mode in Next.js

8 min read

A proper dark mode or theme toggle can be tricky to implement. The previous version of this site had a flicker from light to dark on initial page load for instance. Thankfully Next.js has enough tools to solve this problem and more.

But what constitutes a good dark mode? Our requirements:

  • The user should be able to click a toggle to switch between Light and Dark mode.
  • The toggle should save the user's settings, so that future visits use the correct color theme.
  • The default theme should be set to the user's "preferred" color scheme.
  • The site should not flicker on first load.

The first step should be pretty easy, we just need a button-like element that the user can click. Before we create the button though, we need to define the method it will call and provide a way for the button component to know what the current mode is. For this we will create a theme context.

import { createContext, useState, useEffect, useMemo } from 'react';
import { THEME_KEY } from '@utils/constants';

export const ThemeContext = createContext();

export const ThemeProvider = ({ children }) => {
  const [theme, rawSetTheme] = useState(undefined);

  useEffect(() => {
    const savedTheme = window.localStorage.getItem(THEME_KEY);
    rawSetTheme(savedTheme);
  }, []);

  const contextValue = useMemo(() => {
    function setTheme(newValue) {
      rawSetTheme(newValue);
      window.localStorage.setItem(THEME_KEY, newValue);
      window.document.body.classList.toggle('light');
      window.document.body.classList.toggle('dark');
    }

    return {
      theme,
      setTheme,
    };
  }, [theme, rawSetTheme]);

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

The ThemeContext sets the initial theme state based on the localStorage value. We'll make sure the theme is always set in localStorage a bit later. The ThemeContext also exposes the current state and a method that sets the state, sets the localStorage item and modifies the body class.

Now we can create the ThemeToggle. It has to call the setTheme method with the opposite of the current theme and indicate visually what the current theme is.

import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';

import ThemeToggleStyles from './ThemeToggleStyles';

export default function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

  const toggleTheme = () => {
    setTheme(theme == 'dark' ? 'light' : 'dark');
  }

  if (!theme) {
    return null;
  }

  return (
    <>
      <button className="theme-toggle" onClick={toggleTheme}>
        <div className="theme-toggle-wheel">
          <p className="theme-toggle-light">

<p className="theme-toggle-dark">

</div> </button> <style jsx> {ThemeToggleStyles} </style> </> ); }

We also prevent the component from rendering until the theme state is set. In order for our ThemeToggle to work, we need to place it inside a ThemeProvider. A good place for the ThemeProvider would be the base of the application. Since the theme is meant to change the whole color layout of the website, putting it at the root of the project enables all the components to access the context.

export default function BaseLayout(props) {
  return (
    <>
      <ThemeProvider> {/* <-- theme provider wrapping our application */}
        <div className="default-site-layout">
          <div className="default-site-layout__content">
            <ThemeToggle /> {/* <-- theme toggle inside the provider */}
            <Header />
            <main>
              {props.content}
            </main>
          </div>
        </div>
      </ThemeProvider>
    </>
  );
};

The BaseLayout here could easily be swapped with the _app component. I like to wrap my pages with a BaseLayout and put the global styles in there instead of the _app. This is to enable the addition of pages that might need a totally different styling from the rest of the site.

Right now we have a toggle that changes the theme of our site, but it does not persist it between session changes. We could read the localStorage and set the initial theme in many places, but in order to prevent the color flickering we need to execute this check before each page render. To achieve this we can make use of the special _document page that Next.js provides us.

import Document, { Html, Main, NextScript } from 'next/document';
import { THEME_KEY } from '@utils/constants';

export default class WebsiteDocument extends Document {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx);
    return { ...initialProps };
  }

  render() {
    return (
      <Html>
        <body>
          <script
            dangerouslySetInnerHTML={{
              __html: `
                (function() {
                  function getInitialTheme() {
                    const savedTheme = window.localStorage.getItem('${THEME_KEY}');
                    const hasSavedTheme = typeof savedTheme === 'string';
                    if (hasSavedTheme) {
                      return savedTheme;
                    }
                    
                    const prefersDarkQuery = window.matchMedia('(prefers-color-scheme: dark)');
                    const hasMediaQueryPreference = typeof prefersDarkQuery.matches === 'boolean';
                    if (hasMediaQueryPreference) {
                      const theme = prefersDarkQuery.matches ? 'dark' : 'light';
                      window.localStorage.setItem('${THEME_KEY}', theme);
                      return theme;
                    }

                    window.localStorage.setItem('${THEME_KEY}', 'light');
                    return 'light';
                  }
                  const theme = getInitialTheme();
                  document.body.classList.add(theme);
                })()
              `,
            }}
          />
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

Placing this logic in a <script> tag before Next.js' <Main> attribute ensures we set the body class before the rest of our app renders, thereby eliminating the potential flicker from the default color mode to the one saved by the user.

And with that we have a complete dark mode that checks off all our requirements. The code used is live on this site and can be found here: website's source code