I recently added automatic dark mode theming to my personal site using Next.js, styled-components, and useDarkmode. This is a short technical look at how it's built.
The solution below worked for a while, but unfortunately suffered from the dreaded "dark mode flicker" - that flash of a white screen you get when an SSR'd page briefly hits the client and renders light mode styles.
Along the way, Josh Comeau wrote an amazing post about implementing a dark mode that perfectly accounts for server side rendering and static generation. The tl;dr is that you have to move to CSS variable land, rather than relying on a Styled Components theme
prop. Styled Components theme switching can only happen on the client at render time, and results in the flash.
For anyone visiting this post from the future, I would highly recommend studying (and re-studying) Josh's post. It is seriously amazing.
If you want to see how his writing translates into a Next.js app, you can look at my pull request to fix the dark mode flicker on this site. Specifically, you'll care about these changes: _document.tsx, colors.ts, DarkMode.tsx and InlineCssVariables.tsx.
The rest of the post below reflects my old approach, and would still be a valid way to think about implementing dark mode if you are not doing any kind of server side rendering or static generation. Although, you probably should be doing those things ☺️
useDarkMode is a useful React hook designed to help people add dark mode to their site. The feature I like the most about this hook is that it respects people's operating system preferences, using prefers-color-scheme
. This means means I don't need to build a manual toggle. Instead, I can infer a person's preference from their operating system settings.
Knowing whether or not a person wants dark mode is just the first step of the problem. Based on this preference, we actually have to dynamically update all the colors in our app.
To do this, I defined light, dark, and default theme objects. The default theme contains non-color related properties like spacing and font sizes. The light and dark objects contain all color related properties that should switch dynamically between modes.
Here's a preview of how my themes are set up:
// Theme.ts
const light = {
bg: {
primary: '#eff0f5',
secondary: '#ffffff',
inset: '#e2e4e8',
input: 'rgba(65,67,78,0.12)'
},
text: {
primary: '#050505',
secondary: '#2f3037',
tertiary: '#525560',
quarternary: '#9194a1',
placeholder: 'rgba(82,85,96,0.5)',
onPrimary: '#ffffff'
},
// ...
}
const dark = {
bg: {
primary: '#050505',
secondary: '#111111',
inset: '#111111',
input: 'rgba(191,193,201,0.12)'
},
text: {
primary: '#fbfbfc',
secondary: '#e3e4e8',
tertiary: '#a9abb6',
quarternary: '#6c6f7e',
placeholder: 'rgba(145,148,161,0.5)',
onPrimary: '#050505'
},
// ...
}
const defaultTheme = {
fontSizes: [
'14px', // 0
'16px', // 1
'18px', // 2
'22px', // 3
'26px', // 4
'32px', // 5
'40px' // 6
],
fontWeights: {
body: 400,
subheading: 500,
link: 600,
bold: 700,
heading: 800,
},
lineHeights: {
body: 1.5,
heading: 1.3,
code: 1.6,
},
// ...
};
export const lightTheme = { ...defaultTheme, ...light }
export const darkTheme = { ...defaultTheme, ...dark }
All of the projects I've built in the last few years have used styled-components
to use CSS directly in JavaScript. If you're new to CSS-in-JS, I would recommend this blog post from @mxstbr: Why I Write CSS in JavaScript.
Styled-components uses a ThemeProvider
component which accepts a theme object as a prop, and then re-exposes that object dynamically to any styled component deeper in your component tree. I used this provider to insert a different theme object depending on a person's dark mode preferences:
// Providers.tsx
import { lightTheme, darkTheme } from '../Theme';
export default ({ children }) => {
// i opt out of localStorage and the built in onChange handler because I want all theming to be based on the user's OS preferences
const { value } = useDarkMode(false, { storageKey: null, onChange: null })
const theme = value ? dark : light
return (
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
);
}
With the ThemeProvider
accepting the dynamic theme object, I can then use my color definitions downstream in my components directly:
const Card = styled.div`
/* ... */
background-color: ${props => props.theme.bg.primary};
color: ${props => props.theme.text.primary};
`
One of the best features of Next.js is the ability to render pages on the server. This gives people faster loading times by moving computationally heavy processing off the client and onto a server. Server-side rendering, or SSR, has many other benefits as well, but it comes with a tradeoff: SSR doesn't know about client-specific preferences like prefers-color-scheme
.
This means that when the page is generated on the server, it can't dynamically choose the correct theme. When the client receives the page and hydrates the JavaScript, it can be out of sync and cause rendering to break.
The solution to this is hacky, but works: wrap the body in an visibility: hidden
div for the server's render cycle. This prevents the flash, but doesn't prevent search engines from accessing meta tags deeper in your tree for SEO. When the client rehydrates, re-render the app with the person's clientside preferences. We can skip this server-side render using React.useEffect
to determine when the app has mounted:
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
const body =
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
// prevents ssr flash for mismatched dark mode
if (!mounted) {
return <div style={{ visibility: 'hidden' }}>{body}</div>
}
These two stripped-down files compose the work outlined above:
// Providers.tsx
import { lightTheme, darkTheme } from '../Theme';
export default ({ children }) => {
const { value } = useDarkMode(false, { storageKey: null, onChange: null })
const theme = value ? darkTheme : lightTheme
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
const body =
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
// prevents ssr flash for mismatched dark mode
if (!mounted) {
return <div style={{ visibility: 'hidden' }}>{body}</div>
}
return body
}
// _app.tsx
import * as React from 'react';
import App from 'next/app';
import Providers from '../components/Providers';
class MyApp extends App {
render() {
const { Component, pageProps } = this.props;
return (
<Providers>
<Component {...pageProps} />
</Providers>
);
}
}
export default MyApp;
I'm really pleased with how much easier prefers-color-scheme
made this process of enabling dark mode. Additionally, the open source work happening with tools like Next.js and useDarkmode is fantastic - what a time saver!
Tweet @ me if you end up using this process to add dark mode to your own site, I'd love to see 🌗
A small favor
Was anything I wrote confusing, outdated, or incorrect? Please let me know! Just write a few words below and I’ll be sure to amend this post with your suggestions.
The email newsletter
Get updates about new posts, new projects, or other meaningful updates to this site delivered to your inbox. Alternatively, you can follow me on Twitter.