AshKeys

Confessions and Confusions of Ashok M A. Personal and Professional Blog.

Ashok Mannolu Arunachalam, How toThemingNextJSTailwind CSS
Back

How to add perfect theme switch to NextJS

I recommend you read Josh's quest for the perfect dark mode ^_^. I took heavy inspiration from his solution but applied for this NextJS blog.

I use tailwind CSS for this blog and so far I am loving it. Let's take a look at the steps to achieve the perfect dark mode with persistance of user's choice.

Most importantly we will know how to avoid the flickering of the theme on fresh load.

Theme helper

I copied josh's code to determine the initialColorPreference and made a theme.helper.js as follows:

js
export function getInitialColorMode() {
const persistedColorPreference = window.localStorage.getItem('color-mode');
const hasPersistedPreference = typeof persistedColorPreference === 'string';
if (hasPersistedPreference) {
return persistedColorPreference;
}
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const hasMediaQueryPreference = typeof mql.matches === 'boolean';
if (hasMediaQueryPreference) {
return mql.matches ? 'dark' : 'light';
}
return 'light';
}

Theme Context

With above helper, we know what is the preferred color. Now, we want to put it in a state and make it available to be toggled by the user.

For that, again as per Josh's suggestion, I am using the React.createContext as follows:

jsx
import React from 'react';
import { getInitialColorMode } from '../scripts/theme.helper';
export const ThemeContext = React.createContext();
export const ThemeProvider = ({ children }) => {
const [colorMode, rawSetColorMode] = React.useState('light');
const setColorMode = (value) => {
rawSetColorMode(value);
window.localStorage.setItem('color-mode', value);
};
React.useEffect(() => {
setColorMode(getInitialColorMode());
}, []);
return (
<ThemeContext.Provider value={{ colorMode, setColorMode }}>
{children}
</ThemeContext.Provider>
);
}

Important thing to note here is that we use useEffect hook to set the color mode on initial load.

Add ThemeProvider to _app.js

Let's wire the ThemeProvider in the _app.js.

jsx
<ThemeProvider>
<ThemeSwitch />
<Component {...pageProps} />
</ThemeProvider>

Now, all of the ThemeProvider children can access the colorMode and setColorMode. Even though, these are available across the app, we are only going to use it in the ThemeSwitch to capture user selection.

Theme Swtich

I use Nextra theme blog so I used the same ThemeSwitch available from the repo. You can download/copy it from here

jsx
import React from 'react';
import { ThemeContext } from './ThemeProvider';
export default function ThemeSwitch() {
const { colorMode, setColorMode } = React.useContext(ThemeContext);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
colorMode === 'dark' && document.documentElement.classList.add('dark');
setMounted(true);
}, [colorMode]);
const toggleTheme = () => {
const theme = document.documentElement.classList.toggle('dark')
? 'dark'
: 'light';
setColorMode(theme);
}
return (
<>
{mounted && (
<span
id="themeSwitch"
aria-label="Toggle Dark Mode"
className="text-current p-2 cursor-pointer mr-3 sm:mr-64 float-right select-none hover:scale-105 hover:-rotate-90 transition-transform"
tabIndex={0}
onMouseDown={toggleTheme}
>
{colorMode === 'dark' ? (
<svg
fill="none"
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
) : (
<svg
fill="none"
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
)}
</span>
)}
</>
)
}

Lot's of things going on here, let's read it out one by one.

Use Theme Context

jsx
const { colorMode, setColorMode } = React.useContext(ThemeContext);

We fetch the context that was created.

If you are new to React like me, remember that it is object destructuring not array destructuring like useState

Add dark class to the document

jsx
React.useEffect(() =>
colorMode === 'dark'
&& document.documentElement.classList.add('dark'),
[colorMode]
);

Yes, we need to use useEffect so that once the document is ready and rendered*, we can add dark class if that was the preferred theme of the user.

Persist user selection

jsx
const toggleTheme = () => {
const theme = document.documentElement.classList.toggle('dark')
? 'dark'
: 'light';
setColorMode(theme);
}

Once we bind this handler to the switch, user can make the selection and keep it store in the state.

classList.toggle returns either true or false based on the action result, true if the dark class was added or false if removed.

Show respective switch icon

Once we update the state with user's selection, it will decide the switch to be displayed to indicate the current theme.

That is exactly what you see in the template.

Flash problem

Steps to reproduce:

  1. In a fresh InCognito window, go to the site.
  2. Toggle default light theme to dark. Now, your choice is saved.
  3. Refresh the window with Ctrl + R or CMD + R
  4. You should see the flickering effect.

Loads with default theme but then gets the stored dark theme value. Now, useEffect in the ThemeSwitch is called and we get our saved theme.

Fix

We gotta update the document classList before all other scripts in NextJS get executed. Let's add the following tag in the Head of _document.js.

jsx
<script
dangerouslySetInnerHTML={{
__html: `
document.documentElement.classList.add(
window.localStorage.getItem('color-mode')
)
`
}}
/>
Old is Gold! ^_^