Dark theme with Stitches and Next.js

Rude Ayelo

/

March 2021

October 2021 update: Updated the code examples to match the Stitches v1 API.

It seems like you can't make a website nowadays without pushing in dark mode. Let's see how it's done using two of my most favourite pieces of software ever: Next.js and Stitches:

First of all, you'll need to setup Stitches and your dark theme:

// stitches.config.ts
import { createCss } from "@stitches/react";

export const stitchesConfig = createCss({
  theme: {
    colors: {
      gray900: "hsl(205,5%,7%)",
      gray700: "hsl(205,5%,25%)",
      gray500: "hsl(205,5%,35%)",
      gray50: "hsl(205,5%,95%)",
      blue500: "hsl(205,90%,45%)",

      // Alias
      primary: "$gray900",
      secondary: "$gray700",
      tertiary: "$gray500",
      link: "$blue500",
      background: "$gray50",
      border: "$gray900",
    },
    // ...
  },
  media: {
    dark: "(prefers-color-scheme: dark)",
  },
});

export const darkTheme = stitchesConfig.createTheme({
  colors: {
    primary: "$gray100",
    secondary: "$gray200",
    tertiary: "$gray300",
    link: "$blue500",
    background: "$gray900",
    border: "$gray100",
  },
});

Stitches relies on CSS custom properties (also known as variables) for the theme tokens so by creating new themes you're basically creating a new set of custom properties attached to a class name, how to apply that class name is up to you.

The simplest solution would be adding a button somewhere in the page with an onClick event that would toggle darkTheme.className on and off whenever it's clicked. But we want to offer something better in terms of user experience so we'll use next-themes by Paco Coursey.

To enable your dark theme you'll need to add the ThemeProvider from next-themes in your _app.js file with a little bit of config:

// _app.tsx
import { ThemeProvider } from "next-themes";
import { darkTheme } from "./stitches.config.ts";

function App({ Component, pageProps }) {
  return (
    <ThemeProvider
      attribute="class"
      defaultTheme="system"
      value={{
        dark: darkTheme.className,
        light: "light",
      }}
    >
      <Component {...pageProps} />
    </ThemeProvider>
  );
}

export default App;
  • By default ThemeProvider adds the current theme name to a data-theme attribute in the html tag, we are modifying that behaviour to use the class attribute instead.
  • When passing a value all of the default themes (light and dark) are overriden so we want to make sure we name the light theme even if we're not using the class name at all.

Finally, we want some mechanism to switch between themes. We'll use information that's only available in the client so to avoid an hydration mismatch in the server we'll need to delay rendering the component until it's mounted.

// ./ThemeToggle.tsx
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";

export const ThemeToggle = () => {
  const [mounted, setMounted] = useState(false);
  const { setTheme, resolvedTheme } = useTheme();

  useEffect(() => setMounted(true), []);

  if (!mounted) return null;

  const toggleTheme = () => {
    const targetTheme = resolvedTheme === "light" ? "dark" : "light";

    setTheme(targetTheme);
  };

  return (
    <button className={toggleButton()} onClick={toggleTheme}>
      Switch theme
    </button>
  );
};

And done! You can try it here:

Handling the FODT (Flash Of Default Theme)

We might (should) be generating our static pages at build time, using the above technique alone will cause a flash of light theme for users with dark theme enabled at the system level.

To fix that issue we'll need some global styles to manually override the theme tokens when the user prefers a dark color scheme:

// stitches.config.tsx
export const globalStyles = stitchesConfig.globalCss({
  "@dark": {
    // notice the `media` definition on the stitches.config.ts file
    ":root:not(.light)": {
      ...Object.keys(darkTheme.colors).reduce((varSet, currentColorKey) => {
        const currentColor = darkTheme.colors[currentColorKey];
        const currentColorValue =
          currentColor.value.substring(0, 1) === "$"
            ? `$colors${currentColor.value}`
            : currentColor.value;

        return {
          [currentColor.variable]: currentColorValue,
          ...varSet,
        };
      }, {}),
    },
  },
});

globalStyles();