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.

useDarkMode

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.

Theme Objects

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 }
Using theme objects in styled-components

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};
`
Client-server mismatches

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>
}

Putting it all together

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;
Demo
Conclusion

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 🌗