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:
- Store Theme Preference in
localStorage
; - Pass the Theme Preference Down to Components via Context;
- Add the Ability to Switch Themes;
- 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:
- 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.
- Current theme via the
- 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.