Dark and Light Theme with React & Styled Components

November 1, 2022

3 min read

Dark and Light Theme with React & Styled Components
Watch on YouTube

Let's figure out how to make bulletproof dark and light mode support with styled components for a React app.

import { ComponentWithChildrenProps } from "lib/shared/props"
import { PrefferedThemeProvider } from "lib/ui/theme/PrefferedThemeProvider"
import { ThemePreference } from "lib/ui/theme/ThemePreference"
import {
  PersistentStorageKey,
  usePersistentStorageValue,
} from "state/persistentStorage"

export const ThemeProvider = ({ children }: ComponentWithChildrenProps) => {
  const [prefferedTheme, setPrefferedTheme] =
    usePersistentStorageValue<ThemePreference>(
      PersistentStorageKey.ThemePreference,
      "system"
    )

  return (
    <PrefferedThemeProvider
      prefferedTheme={prefferedTheme}
      setPrefferedTheme={setPrefferedTheme}
    >
      {children}
    </PrefferedThemeProvider>
  )
}

Here we have a theme provider that takes the current theme name from local storage and passes it to the PrefferedThemeProvider component with a function for changing the theme. You can find a video on local storage and React here. In the PrefferedThemeProvider component, we check if the system color is dark with a media query and update the current theme in the use effect hook. To make styled components aware of the selected theme, we use ThemeProvider. Also, we want to provide a way for the user to change between dark and light modes, so we have a provider for PrefferedThemeContext. In the demo, we have a ThemeToggleButton, that will change the theme to the opposite one. At increaser.org, I have a theme selection on the settings page, where you can also select a system mode.

import { createContext, useEffect, useMemo, useState } from "react"
import { useMedia } from "react-use"
import { ComponentWithChildrenProps } from "lib/shared/props"
import { DefaultTheme, ThemeProvider } from "styled-components"
import { darkTheme } from "lib/ui/theme/darkTheme"

import { lightTheme } from "./lightTheme"
import { ThemePreference } from "./ThemePreference"
import { createContextHook } from "lib/shared/utils/createContextHook"

interface PrefferedThemeState {
  prefferedTheme: ThemePreference
  setPrefferedTheme: (theme: ThemePreference) => void
}

const PrefferedThemeContext = createContext<PrefferedThemeState | undefined>(
  undefined
)

type Props = PrefferedThemeState & ComponentWithChildrenProps

export const PrefferedThemeProvider = ({
  prefferedTheme,
  setPrefferedTheme,
  children,
}: Props) => {
  const isSystemThemeDark = useMedia("(prefers-color-scheme: dark)", false)

  const [theme, setTheme] = useState<DefaultTheme>(darkTheme)
  useEffect(() => {
    if (prefferedTheme === "system") {
      setTheme(isSystemThemeDark ? darkTheme : lightTheme)
    } else {
      setTheme(prefferedTheme === "dark" ? darkTheme : lightTheme)
    }
  }, [isSystemThemeDark, prefferedTheme])

  return (
    <PrefferedThemeContext.Provider
      value={{ prefferedTheme, setPrefferedTheme }}
    >
      <ThemeProvider theme={theme}>{children}</ThemeProvider>
    </PrefferedThemeContext.Provider>
  )
}

export const usePrefferedTheme = createContextHook(
  PrefferedThemeContext,
  "PrefferedThemeContext"
)

I took theme inspiration from Notion, which has quite a minimalistic setup. Both themes have a few shared colors: primary, attention, alert, success, and white. While background, foreground, glass, and text colors are unique to each mode. For most cases, components consume colors without checking for theme name, except in rare situations like a Card component that doesn't change a background in the light mode.