This guide is based on Josh Comeau's very helpful article on the topic. It addresses the challenges of adding flexible dark modes to server-side rendered pages. This post contains my notes on how to implement a similar solution in Next.js apps.
Goal
The final solution should provide:
- Dark mode without a flicker
- A default mode (light or dark) based on the user's OS preference
- A toggle for the user to switch between light and dark themes
- Persistence for the users preferred choice
To achieve this for server-rendered pages, we need to consider rendering as a two-phase process:
- On the server (1st pass render): server render HTML and CSS and send it down the wire along with JS
- On the client (2nd pass render): run a script to derive user preferences and use those to set the relevant CSS custom property values
Requirements
These are the elements that we need to build the final solution:
- CSS custom properties (or CSS variables) for all styles affected by the modes
- A script to run before the page becomes interactive to pull user preference on initial load
- A toggle component for toggling between light and dark modes and persisting the user's choice
Solution
CSS custom properties
CSS variables are powerful in that you set their values dynamically through JavaScript and make them avaiable to all your components by setting them on the root element. This makes them particularly useful for implementing something like dark mode since we can assign a CSS variable as the value of some style and then have that same variable provide styling in both light and dark modes.
We have no access to the user's preference during the first phase of the render, i.e when rendering is done on the server. As such, we need to run a script on the user browser and access the user's preference through the prefers-color-scheme
CSS media feature. We can then set the CSS variable values based on the preference and render the markup that is now ready. Since the script is render blocking, there should be no flicker when applying the mode.
Adding a script for detecting the user's theme preference on load
We need to run an inline script before the rest of the HTML is parsed so that the theme values are ready before the DOM elements are rendered. Mine looks like the following:
import { COLORS } from 'styles/colors';
export const initDarkModeScript = () => {
let codeToRunOnClient = `
(function() {
function getInitialColorMode() {
const persistedColorPreference = window.localStorage.getItem('color-mode');
const hasPersistedPreference = typeof persistedColorPreference === 'string';
// If the user has explicitly chosen light or dark,
// let's use it. Otherwise, this value will be null.
if (hasPersistedPreference) {
return persistedColorPreference;
}
// If they haven't been explicit, let's check the media
// query
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const hasMediaQueryPreference = typeof mql.matches === 'boolean';
if (hasMediaQueryPreference) {
return mql.matches ? 'dark' : 'light';
}
// If they are using a browser/OS that doesn't support
// color themes, let's default to 'light'.
return 'light';
}
const colorMode = getInitialColorMode();
const root = document.documentElement;
root.style.setProperty(
'--color-text',
colorMode === 'light'
? '${COLORS.light.text}'
: '${COLORS.dark.text}'
);
root.style.setProperty(
'--color-background',
colorMode === 'light'
? '${COLORS.light.background}'
: '${COLORS.dark.background}'
);
// ... other assignments ommited for brevity
})()`;
return (
<script
dangerouslySetInnerHTM={{
__html: codeToRunOnClient,
}}
></script>
);
};
So, we define the script as an immediately invoked function expression (IIFE) within backticks to have it in the format that dangerouslySetInnerHTML
expects. We then flesh out the script element and return it from the file.
We can then import this to use in our client-side _app
component like so:
// other imports ommited for brevity
import { initDarkModeScript } from 'components/DarkModeScript';
function MyApp({ Component, pageProps }) {
return (
<>
<Head>{initDarkModeScript()}</Head>
<GlobalStyle />
<Providers>
<Layout>
<Component {...pageProps} />
</Layout>
</Providers>
</>
);
}
This injects the scripts into the head of the HTML document to be run before the HTML is fully parsed. When it runs, we have our --initial-color-mode
CSS variable set to the user's intiial preference, which we can then use in the toggle component that we'll add.
As a side node, Next.js has a built-in Script component to control the loading behaviour of internal or third-party scripts. However, the component is limited for inline scripts in that inline scripts cannot be run with the beforeInteractive
strategy, which is why I defaulted to the vanilla script tag.
Giving the user the ability to toggle between light and dark mode
Now that the CSS variable values are initialised on page load, we can add a toggle for the user to switch freely between light and dark mode.
For this we need to:
- Store the preferred mode in state (using the Context API) — our theme context
- Add a toggle component that uses the theme context to read and set the theme value
My Providers.tsx
file looks like the following:
import React from 'react'
import { ThemeProvider } from 'styled-components'
import { COLORS } from 'styles/colors'
import theme from 'styles/theme'
export const ThemeContext = React.createContext<{
colorMode: string
setColorMode: React.Dispatch<string>
} | null>(null)
export const Providers = ({ children }) => {
const [colorMode, rawSetColorMode] = React.useState<string | undefined>(
undefined,
)
React.useEffect(() => {
const root = window.document.documentElement
// this was set when the initialisaiton script ran
// we can now access it
const initialColorValue = root.style.getPropertyValue(
'--initial-color-mode',
)
rawSetColorMode(initialColorValue)
}, [])
const setColorMode = (newValue: string) => {
const root = window.document.documentElement
// 1. Update React color-mode state
rawSetColorMode(newValue)
// 2. Update localStorage
localStorage.setItem('color-mode', newValue)
// 3. Update each color
root.style.setProperty(
'--color-text',
newValue === 'light' ? COLORS.light.text : COLORS.dark.text,
)
root.style.setProperty(
'--color-background',
newValue === 'light' ? COLORS.light.background : COLORS.dark.background,
)
// rest of the assignments omitted for brevity
)
}
return (
<ThemeContext.Provider value={{ colorMode, setColorMode }}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ThemeContext.Provider>
)
}
Now that we have the logic needed to read and update the color mode, we can connect a toggle component to it. I have a 'DarkModeToggle' component that looks like the following:
import React from 'react';
import { BiMoon, BiSun } from 'react-icons/bi';
import { ThemeContext } from './Providers';
export const DarkToggle = () => {
// access the theme context we just created
const { colorMode, setColorMode } = React.useContext(ThemeContext);
// render a button icon based on the color mode
return colorMode === 'light' ? (
<button
aria-label='Activate dark mode'
onClick={()=> {
setColorMode('dark');
}}
>
<BiMoon size={40} title='dark mode moon icon' color={'var(--color-text)'} />{' '}
</button>
) : (
<button
aria-label='Activate light mode'
onClick={()=> {
setColorMode('light');
}}
>
<BiSun size={40} title='light mode sun icon' color={'var(--color-text)'} />
</button>
);
};
With the toggle component in place, everything works as desired — we have a fully functional dark mode that respects the user's default preference and provides them the ability to toggle the mode freely.
Testing the solution
To test that everything works snoothly. In particular, that there is no flicker:
- Create a production build with
next build
- Run the production build with
next start
- Open DevTools and set CPU throttle to either 4 or 6x, and observe the result.
Make sure to test this on a production build rather than a dev one. There should be no flicker in which the wrong colors are breifly shown.
And that's it.
Thoughts
Altough somewhat involved, I think this is a great solution for projects that do not use CSS frameworks that provide theming solutions out-of-the-box. Ultimately, it would be better to have a non-JS solution (if that's possible). I also feel that this is something that a framework could and should take care of.