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:
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:
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
.
<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
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 && (<spanid="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' ? (<svgfill="none"viewBox="0 0 24 24"width="24"height="24"stroke="currentColor"><pathstrokeLinecap="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>) : (<svgfill="none"viewBox="0 0 24 24"width="24"height="24"stroke="currentColor"><pathstrokeLinecap="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
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
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
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 thedark
class was added orfalse
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:
- In a fresh InCognito window, go to the site.
- Toggle default light theme to dark. Now, your choice is saved.
- Refresh the window with
Ctrl + R
orCMD + R
- You should see the flickering effect.
Loads with default theme but then gets the stored
dark
theme value. Now,useEffect
in theThemeSwitch
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
.
<scriptdangerouslySetInnerHTML={{__html: `document.documentElement.classList.add(window.localStorage.getItem('color-mode'))`}}/>
Old is Gold! ^_^