Adding a Performant, Flicker-Free Dark Mode to Next.js Apps

April 3, 2022

This guide is based on Josh Comeau's very helpful article on the topic. It addresses the challenges of adding flexible dark modes to server-side rendered pages. This post contains my notes on how to implement a similar solution in Next.js apps.

Goal

The final solution should provide:

  • Dark mode without a flicker
  • A default mode (light or dark) based on the user's OS preference
  • A toggle for the user to switch between light and dark themes
  • Persistence for the users preferred choice

To achieve this for server-rendered pages, we need to consider rendering as a two-phase process:

  1. On the server (1st pass render): server render HTML and CSS and send it down the wire along with JS
  2. On the client (2nd pass render): run a script to derive user preferences and use those to set the relevant CSS custom property values

Requirements

These are the elements that we need to build the final solution:

  1. CSS custom properties (or CSS variables) for all styles affected by the modes
  2. A script to run before the page becomes interactive to pull user preference on initial load
  3. A toggle component for toggling between light and dark modes and persisting the user's choice

Solution

CSS custom properties

CSS variables are powerful in that you set their values dynamically through JavaScript and make them avaiable to all your components by setting them on the root element. This makes them particularly useful for implementing something like dark mode since we can assign a CSS variable as the value of some style and then have that same variable provide styling in both light and dark modes.

We have no access to the user's preference during the first phase of the render, i.e when rendering is done on the server. As such, we need to run a script on the user browser and access the user's preference through the prefers-color-scheme CSS media feature. We can then set the CSS variable values based on the preference and render the markup that is now ready. Since the script is render blocking, there should be no flicker when applying the mode.

Adding a script for detecting the user's theme preference on load

We need to run an inline script before the rest of the HTML is parsed so that the theme values are ready before the DOM elements are rendered. Mine looks like the following:

import { COLORS } from 'styles/colors';

export const initDarkModeScript = () => {
  let codeToRunOnClient = `
  (function() {
    function getInitialColorMode() {
      const persistedColorPreference = window.localStorage.getItem('color-mode');
      const hasPersistedPreference = typeof persistedColorPreference === 'string';
      // If the user has explicitly chosen light or dark,
      // let's use it. Otherwise, this value will be null.
      if (hasPersistedPreference) {
        return persistedColorPreference;
      }
      // If they haven't been explicit, let's check the media
      // query
      const mql = window.matchMedia('(prefers-color-scheme: dark)');
      const hasMediaQueryPreference = typeof mql.matches === 'boolean';
      if (hasMediaQueryPreference) {
        return mql.matches ? 'dark' : 'light';
      }
      // If they are using a browser/OS that doesn't support
      // color themes, let's default to 'light'.
      return 'light';
    }
    const colorMode = getInitialColorMode();
    const root = document.documentElement;
    root.style.setProperty(
      '--color-text',
      colorMode === 'light'
        ? '${COLORS.light.text}'
        : '${COLORS.dark.text}'
    );
    root.style.setProperty(
      '--color-background',
      colorMode === 'light'
        ? '${COLORS.light.background}'
        : '${COLORS.dark.background}'
    );
    // ... other assignments ommited for brevity
  })()`;

  return (
    <script
      dangerouslySetInnerHTM={{
        __html: codeToRunOnClient,
      }}
    ></script>
  );
};

So, we define the script as an immediately invoked function expression (IIFE) within backticks to have it in the format that dangerouslySetInnerHTML expects. We then flesh out the script element and return it from the file.

We can then import this to use in our client-side _app component like so:

// other imports ommited  for brevity
import { initDarkModeScript } from 'components/DarkModeScript';

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Head>{initDarkModeScript()}</Head>
      <GlobalStyle />
      <Providers>
        <Layout>
          <Component {...pageProps} />
        </Layout>
      </Providers>
    </>
  );
}

This injects the scripts into the head of the HTML document to be run before the HTML is fully parsed. When it runs, we have our --initial-color-mode CSS variable set to the user's intiial preference, which we can then use in the toggle component that we'll add.

As a side node, Next.js has a built-in Script component to control the loading behaviour of internal or third-party scripts. However, the component is limited for inline scripts in that inline scripts cannot be run with the beforeInteractive strategy, which is why I defaulted to the vanilla script tag.

Giving the user the ability to toggle between light and dark mode

Now that the CSS variable values are initialised on page load, we can add a toggle for the user to switch freely between light and dark mode.

For this we need to:

  1. Store the preferred mode in state (using the Context API) — our theme context
  2. Add a toggle component that uses the theme context to read and set the theme value

My Providers.tsx file looks like the following:

import React from 'react'
import { ThemeProvider } from 'styled-components'
import { COLORS } from 'styles/colors'
import theme from 'styles/theme'

export const ThemeContext = React.createContext<{
  colorMode: string
  setColorMode: React.Dispatch<string>
} | null>(null)

export const Providers = ({ children }) => {
  const [colorMode, rawSetColorMode] = React.useState<string | undefined>(
    undefined,
  )

  React.useEffect(() => {
    const root = window.document.documentElement
    // this was set when the initialisaiton script ran
    // we can now access it
    const initialColorValue = root.style.getPropertyValue(
      '--initial-color-mode',
    )
    rawSetColorMode(initialColorValue)
  }, [])

  const setColorMode = (newValue: string) => {
    const root = window.document.documentElement
    // 1. Update React color-mode state
    rawSetColorMode(newValue)
    // 2. Update localStorage
    localStorage.setItem('color-mode', newValue)
    // 3. Update each color
    root.style.setProperty(
      '--color-text',
      newValue === 'light' ? COLORS.light.text : COLORS.dark.text,
    )
    root.style.setProperty(
      '--color-background',
      newValue === 'light' ? COLORS.light.background : COLORS.dark.background,
    )
    // rest of the assignments omitted for brevity
    )
  }

  return (
    <ThemeContext.Provider value={{ colorMode, setColorMode }}>
      <ThemeProvider theme={theme}>{children}</ThemeProvider>
    </ThemeContext.Provider>
  )
}

Now that we have the logic needed to read and update the color mode, we can connect a toggle component to it. I have a 'DarkModeToggle' component that looks like the following:

import React from 'react';
import { BiMoon, BiSun } from 'react-icons/bi';
import { ThemeContext } from './Providers';

export const DarkToggle = () => {
  // access the theme context we just created
  const { colorMode, setColorMode } = React.useContext(ThemeContext);

  // render a button icon based on the color mode
  return colorMode === 'light' ? (
    <button
      aria-label='Activate dark mode'
      onClick={()=> {
        setColorMode('dark');
      }}
    >
      <BiMoon size={40} title='dark mode moon icon' color={'var(--color-text)'} />{' '}
    </button>
  ) : (
    <button
      aria-label='Activate light mode'
      onClick={()=> {
        setColorMode('light');
      }}
    >
      <BiSun size={40} title='light mode sun icon' color={'var(--color-text)'} />
    </button>
  );
};

With the toggle component in place, everything works as desired — we have a fully functional dark mode that respects the user's default preference and provides them the ability to toggle the mode freely.

Testing the solution

To test that everything works snoothly. In particular, that there is no flicker:

  1. Create a production build with next build
  2. Run the production build with next start
  3. Open DevTools and set CPU throttle to either 4 or 6x, and observe the result.

Make sure to test this on a production build rather than a dev one. There should be no flicker in which the wrong colors are breifly shown.

And that's it.

Thoughts

Altough somewhat involved, I think this is a great solution for projects that do not use CSS frameworks that provide theming solutions out-of-the-box. Ultimately, it would be better to have a non-JS solution (if that's possible). I also feel that this is something that a framework could and should take care of.