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

In Next.js 13+, you can create a dark/light mode toggle, that does not cause flickering, by following these steps:

  1. Managing Theme Preference;
  2. Passing Theme Preference Down to Components;
  3. Switching Theme;
  4. Avoiding Flicker.

Managing Theme Preference

Storing the Theme Preference

By storing the user's theme preference inside a cookie, you can easily read the value on the client side, as well as on the server side. The benefit of this approach is that, you won't see a flicker when the page loads initially, as you would've already determined the preferred theme on the server side. Consequently, you don't have to wait for the first render on the client side to determine the theme.

To implement this, begin by creating a helper file to manage cookies, which includes functions to read, write, and erase cookies on the client side. For example:

// helper/cookie.helper
export const getCookie = (name) => {
  const matches = `; ${document.cookie}`.match(`;\\s*${name}=([^;]+)`);
  return matches ? matches[1] : null;
}

export const setCookie = (name, value, days) => {
  let expires = '';

  if (days) {
    const date = new Date();
    date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
    expires = `; expires=${date.toGMTString()}`;
  }

  document.cookie = `${name}=${value}${expires}; path=/`;
}

export const eraseCookie = (name) => {
  setCookie(name, '', -1);
}

To not be tightly coupled with cookies, you can consider creating a "file storage adapter" of some sort (that uses cookies internally). This will allow you to easily swap out storage mechanisms if need be in the future.

Managing the Theme Preference

Develop a theme helper file that manages getting, setting, and applying theme preferences, like the following:

// helper/theme.helper.js
import { getCookie, setCookie, eraseCookie } from '@/helper/cookie.helper';

const THEME_DARK = 'dark'
const THEME_LIGHT = 'light';

const applyPreference = (theme) => {
  const root = document.firstElementChild;
  root.classList.remove(THEME_LIGHT, THEME_DARK);
  root.classList.add(theme);
  root.style.colorScheme = theme;
};

export const getPreference = (storageKey) => {
  const cookie = getCookie(storageKey);

  if (cookie) {
    return cookie;
  }

  return window.matchMedia(`(prefers-color-scheme: ${THEME_DARK})`).matches ? THEME_DARK : THEME_LIGHT;
};

export const setPreference = (storageKey, theme) => {
  eraseCookie(storageKey);
  setCookie(storageKey, theme, 365);
  applyPreference(theme);
}

export const getColors = () => ({ dark: THEME_DARK, light: THEME_LIGHT });

These functions help you with the following:

  1. applyPreference() allows you to apply the theme color preference to the document root element (i.e. <html>);
  2. getPreference() allows you to get the current theme color preference via cookie or preferred system color scheme;
  3. setPreference() allows you to set a new preferred theme color that persists in a cookie, overwriting any older values;
  4. getColors() allows you to get the default colors.

Passing Theme Preference Down to Components

To pass the theme preference down to other components via context, you need to create a theme context, a custom hook and a theme provider:

Creating Theme Context

Create a theme context that components can provide or read:

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

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

These default values are overridden by the provider.

Creating useTheme() Hook

A common pattern is to wrap the context in a custom hook to make the code more clean, maintainable, scalable and reusable:

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

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

Create Theme Provider

Wrap your root layout code with a theme provider to pass down the theme preference via context:

// context/ThemeProvider.jsx
'use client'

import React, { useEffect, useState, useCallback } from 'react';
import ThemeContext from '@/context/ThemeContext';
import { getPreference, setPreference, getColors } from '@/helper/theme.helper';

const color = getColors();

export default function ThemeProvider({ children, storageKey, theme: startTheme }) {
  const [theme, setTheme] = useState(startTheme ?? getPreference(storageKey));

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

  const toggleTheme = useCallback(() => {
    setTheme(theme === color.dark ? color.light : color.dark);
  }, [theme, setTheme]);

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

In this code, the provider:

  1. Wraps around child components, passing down the following:
    • Current theme via the theme property;
    • Colors for dark and light mode via the color property;
    • The ability to change theme via the setTheme() function;
    • The ability to toggle theme via the toggleTheme() function.
  2. Accepts the following props:
    • storageKey that's used as a key for storing the theme preference;
    • theme that allows specifying an initial theme coming from the server side to avoid flicker on page load.

After you've created the provider, you need to wrap it around all components you wish to pass the context to. For example, you can do so in the root layout.js file (in the app folder) to pass the theme context down 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>
  )
}

Switching Theme

To be able to switch/toggle between dark/light themes, you need an element that triggers it and some styling that applies the visual change. Below are a few examples in which you can achieve this:

Create Toggle Theme Button

Now, you need to get the toggleTheme() function from the useTheme() hook to create a toggle button:

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

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

export default function ToggleThemeButton() {
  const { toggleTheme } = useTheme();

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

You can also toggle the theme manually, by updating the onClick handler code using theme, color and setTheme() via the useTheme() hook, for example, as follows:

// ...
  const { theme, color, setTheme } = useTheme();

  return (
    <button
      onClick={() => setTheme(theme === color.dark ? color.light : color.dark)}
    >
      Toggle Theme
    </button>
  )
// ...

If you're using shadcn/ui then you can try the following stylized snippet:

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

import React from 'react';
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import useTheme from '@/hook/useTheme';

export default function ToggleThemeButton() {
  const { toggleTheme } = useTheme();

  return (
    <Button onClick={toggleTheme} size="icon">
      <Sun className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
      <Moon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
    </Button>
  )
}

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

// 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, every time you click on the toggle button, the <html> element should have the corresponding theme preference added to it.

Create Dark and Light Styles

You can set the light theme as the default, and override the style properties in the dark theme CSS selector. You can do so in the following three ways:

  1. Using :root, :root.light and/or :root.dark selectors;
  2. Using .dark and/or .light class names, as one of those will be set on the root element (<html>);
  3. Using @media (prefers-color-scheme: ...) with dark or light values and specifying styles there.

For example, all the following are valid approaches:

// 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

If you've followed along so far, and got a working solution, you might have noticed that the page always loads in white color first, and then the dark theme is applied after a few seconds, causing an unwanted flicker. To solve this problem, you can read the theme preference cookie on the server side (for example, using the cookies() function from the next/headers package), and pass it down to the ThemeProvider:

// app/layout.js
import { cookies } from 'next/headers';
import ThemeProvider from '@/context/ThemeProvider';

const THEME_STORAGE_KEY = 'theme-preference';

export default async function RootLayout() {
  const theme = cookies().get(THEME_STORAGE_KEY)?.value;

  return (
    <html lang="en" className={theme} style={{ colorScheme: theme }}>
      <body>
        <ThemeProvider storageKey={THEME_STORAGE_KEY} theme={theme}>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

If you do not wish to manually add the class name and color-scheme property to the <html> element, then you can modify the ThemeProvider and inject an inline script that applies these to the root element automatically:

// context/ThemeProvider.jsx
'use client'

import React, { useEffect, useState, useCallback, memo } from 'react';
import ThemeContext from '@/context/ThemeContext';
import { getPreference, setPreference, getColors } from '@/helper/theme.helper';

const color = getColors();

const AntiFlickerScript = memo(function Script({ theme, color }) {
  const script = (() => `(function(theme,root){root.classList.remove(\`'${Object.values(color).join("','")}'\`);root.classList.add(theme);root.style.colorScheme=theme;})('${theme}',document.firstElementChild)`)();
  return <script dangerouslySetInnerHTML={{ __html: script }} />
}, () => true);

export default function ThemeProvider({ children, storageKey, theme: startTheme }) {
  const [theme, setTheme] = useState(startTheme ?? getPreference(storageKey));

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

  const toggleTheme = useCallback(() => {
    setTheme(theme === color.dark ? color.light : color.dark);
  }, [theme, setTheme]);

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

The injected script has the same code as the applyPreference() function from earlier. However, be aware that this method can cause warnings from SSR. In that case, it might be better to manually add the theme to the className and the color-scheme CSS property.

Based on the changes above, you can update the code in your root layout file as follows:

// app/layout.js
import { cookies } from 'next/headers';
import ThemeProvider from '@/context/ThemeProvider';

const THEME_STORAGE_KEY = 'theme-preference';

export default async function RootLayout() {
  const theme = cookies().get(THEME_STORAGE_KEY)?.value;

  return (
    <html lang="en">
      <body>
        <ThemeProvider storageKey={THEME_STORAGE_KEY} theme={theme}>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

There could still be one case where there might be a flicker; when the user lands on your website for the first time with no preferences set, and their system preferences are set to opposite of what you have as the default theme. However, this would only happen once at the first page load, so it may not be a big nuisance.


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.