How to Create Non-Flickering Dark/Light Mode Toggle in Next.js Using localStorage?

The simplest way to create a dark/light theme toggle in Next.js v13+ is to add the following attributes to the <html> element:

<html class="dark" style="color-scheme:dark">
  <!-- ... -->
</html>

To achieve this programmatically, you can do the following:

  1. Store Theme Preference in localStorage;
  2. Pass the Theme Preference Down to Components via Context;
  3. Add the Ability to Switch Themes;
  4. Avoid Flicker on Page Load.

You may build this from scratch, or you can use the @designcise/next-theme-toggle npm package that does the exact same thing.

Storing Theme Preference in localStorage

One way to share the user's theme preference across different pages is to manage it via the localStorage. The benefit of this approach is that it's entirely handled on the client-side, avoiding dynamic rendering of the page on the server-side, as would be the case with a cookie-based approach.

Start by creating a storage adapter:

// adapter/storage.adapter.js
export const read = (key) => localStorage.getItem(key);
export const write = (key, value) => localStorage.setItem(key, value);
export const erase = (key) => localStorage.removeItem(key);

Next, create a theme helper file:

// helper/theme.helper.js
import { read, write, erase } from '@/adapter/storage.adapter';

export const themes = { dark: 'dark', light: 'light' };

const applyTheme = (theme) => {
  const root = document.firstElementChild;
  root.classList.remove(themes.dark, themes.light);
  root.classList.add(theme);
  root.style.colorScheme = theme;
};

export const saveTheme = (storageKey, theme) => {
  erase(storageKey);
  write(storageKey, theme);
  applyTheme(theme);
};

export const getTheme = (storageKey, defaultTheme) => {
  if (typeof window === "undefined") {
    return defaultTheme;
  }

  return (
    read(storageKey) ??
    defaultTheme ??
    (window.matchMedia(`(prefers-color-scheme: ${themes.dark})`).matches
      ? themes.dark
      : themes.light)
  );
};

These helper functions will assist you with getting, setting, and applying user's theme preference.

Passing the Theme Preference Down to Components via Context

For components to access the current theme state, create a theme context, a custom hook, and a theme provider:

Creating Theme Context

Creating a theme context enables components to either share or retrieve values that are propagated down the component tree. You can use the createContext() function to create a context with default values, as shown below:

// context/ThemeContext.js
import { createContext } from 'react';

export default createContext({
  theme: undefined,
  themes: undefined,
  setTheme: () => {},
});

Please note that the values established in the provider will take precedence over the default values specified here.

Creating useTheme() Hook

While not strictly mandatory, encapsulating the context within a custom hook is a common pattern:

// hook/useTheme.js
import { useContext } from 'react';
import ThemeContext from '@/context/ThemeContext';

export default function useTheme() {
  return useContext(ThemeContext);
}

This will help you make the code more clean, maintainable, scalable and reusable.

Create Theme Provider

To be able to pass down theme preferences via context, it's essential to create a theme context provider, like the following:

// context/ThemeProvider.jsx
'use client'

import React, { useEffect, useState } from 'react';
import ThemeContext from '@/context/ThemeContext';
import { getTheme, saveTheme, themes } from '@/helper/theme.helper';

export default function ThemeProvider({ children, storageKey, defaultTheme }) {
  const [theme, setTheme] = useState(getTheme(storageKey, defaultTheme));

  useEffect(() => {
    saveTheme(storageKey, theme);
  }, [storageKey, theme]);

  return (
    <ThemeContext.Provider value={{ theme, themes, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

In this code, the provider:

  1. Wraps around child components, passing down the following:
    • Current theme via the theme property;
    • Supported themes via the themes property;
    • The ability to change theme via the setTheme() function.
  2. Accepts the following props:
    • storageKey that's used as a key for storing the theme preference;
    • defaultTheme that allows specifying an optional default theme.

After creating the provider, ensure that you wrap it around all components requiring access to the theme context. For example, you can do so in the root layout.js file (within the "app" folder) to pass the theme context to all components:

// app/layout.js
import ThemeProvider from '@/context/ThemeProvider';

const THEME_STORAGE_KEY = 'theme-preference';

export default async function RootLayout() {
  return (
    <html lang="en">
      <body>
        <ThemeProvider storageKey={THEME_STORAGE_KEY}>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Adding the Ability to Switch Themes

To let users switch between dark and light themes, you'll want to add a button like the following for example, that triggers the theme switch:

// components/ToggleThemeButton/index.jsx
'use client'

import React from 'react';
import useTheme from '@/hook/useTheme';

export default function ToggleThemeButton() {
  const { theme, themes, setTheme } = useTheme();
  const toggleTheme = () => setTheme((theme === themes.dark) ? themes.light : themes.dark);

  return (
    <button onClick={toggleTheme}>Toggle Theme</button>
  )
}

Once you've created this component, you can use it in your page.js file in the app folder, for example, in the following way:

// app/page.js
import ToggleThemeButton from '@/components/ToggleThemeButton';

export default async function Home() {
  return (
    <main>
      <h1>Hello World</h1>

      <ToggleThemeButton />
    </main>
  )
}

In the resulting page, clicking the toggle button should dynamically apply the selected theme preference to the <html> element.

To visually see the changes, you can customize styles using CSS selectors that target dark and light modes. Below are a few examples demonstrating different approaches to creating selectors for dark and light themes:

/* globals.css */
:root body {
  background: white;
}

:root.dark body {
  background: black;
}
/* globals.css */
body {
  background: white;
}

.dark body {
  background: black;
}
/* globals.css */
body {
  background: white;
}

@media (prefers-color-scheme: dark) {
  body {
    background: black;
  }
}

Avoiding Flicker on Page Load

Managing theme switching on the client side poses a notable challenge, as the preferred theme becomes effective only after the component is loaded and rendered, leading to a momentary flicker/flash in certain cases. An effective, client-side only solution, involves injecting an inline script into the DOM to swiftly apply the theme. Following is a example of how this inline script could be coded:

// component/AntiFlickerScript.jsx
import React, { memo } from 'react';
import { themes } from '@/helper/theme.helper';

export default memo(function AntiFlickerScript({ storageKey, theme }) {
  const classList = Object.values(themes).join("','");
  const preferredTheme = `localStorage.getItem('${storageKey}')`;
  const fallbackTheme = theme
    ?? `(window.matchMedia('(prefers-color-scheme: ${themes.dark})').matches?'${themes.dark}':'${themes.light}')`;
  const script = `(function(root){const theme=${preferredTheme}??${fallbackTheme};root.classList.remove('${classList}');root.classList.add(theme);root.style.colorScheme=theme;})(document.firstElementChild)`;

  return <script dangerouslySetInnerHTML={{ __html: script }} />
}, () => true);

To inject this script into the DOM, you can add it to the ThemeProvider file as follows:

// context/ThemeProvider.jsx
'use client'

import React, { useEffect, useState, useCallback } from 'react';
import ThemeContext from '@/context/ThemeContext';
import AntiFlickerScript from '@/components/AntiFlickerScript';
import { getTheme, saveTheme, themes } from '@/helper/theme.helper';

export default function ThemeProvider({ children, storageKey, defaultTheme }) {
  const [theme, setTheme] = useState(getTheme(storageKey, defaultTheme));

  useEffect(() => {
    saveTheme(storageKey, theme);
  }, [storageKey, theme]);

  return (
    <ThemeContext.Provider value={{ theme, themes, setTheme }}>
      <AntiFlickerScript storageKey={storageKey} theme={defaultTheme} />
        {children}
    </ThemeContext.Provider>
  );
}

The injected script has the same code as the applyTheme() function from earlier. However, be aware that this method may cause the following warning in dev build:

Warning: Extra attributes from the server: class,style

This happens because it is expected that the hydrated layout from server to client would be exactly the same. With the injected inline script, however, an additional class and style attribute is added to the html element, which does not originally exist on the server-side generated page. This leads to a mismatch in the server-side and client-side rendered page, and thus, a warning is shown. This warning, however, is only shown on dev build and can safely be ignored. If this becomes an issue for you, you may want to explore the alternative approach using cookies.


This post was published (and was last revised ) by Daniyal Hamid. Daniyal currently works as the Head of Engineering in Germany and has 20+ years of experience in software engineering, design and marketing. Please show your love and support by sharing this post.