DEV Community

Cover image for Step-By-Step Guide to Adding Dark Mode and Multiple Themes to Your Next.js App
Luis Cadillo
Luis Cadillo

Posted on

Step-By-Step Guide to Adding Dark Mode and Multiple Themes to Your Next.js App

Adding themes to your Next.js app makes it possible for your users to personalize their experience and improve the usability of your product. With themes, users can easily switch between light and dark modes, or change the color scheme of the app, depending on their preference. In this tutorial, I will show you how to set up dark mode and add other themes to your Next.js app using the next-theme library, TailwindCSS, and CSS variables. So, let's get started!

Getting started

Next.js + TailwindCSS

You can follow the Install Tailwind CSS with Next.js guide.

Next-themes

This package will do the “magic”, it provides a custom useTheme hook to consume and change the current theme. It can also select between dark and light modes based on the user’s system-preferred color theme. To install it, all you need to do is run the following command in your project's directory:

npm install next-themes
# or
yarn add next-themes
Enter fullscreen mode Exit fullscreen mode

Setting up dark mode

The most common use of the next-themes library is setting up dark mode. Later, we will explore how to add more themes to your app.

  • Wrap <Component /> with <ThemeProvider /> exported from next-themes:
// pages/_app.tsx

import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider attribute="class">
      <Component {...pageProps} />
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Add ’class’ to darkMode option in the tailwind.config.js:
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: "class",
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

We’re opting out of letting the operating system automatically modify the app’s theme, which is set via darkMode: “media“, in favor of toggling dark mode manually.

  • We’re now able to use the useTheme hook provided by the library to change the current theme:
// pages/index.tsx

import { useTheme } from "next-themes";

export default function Home() {
  const { resolvedTheme, setTheme } = useTheme();

  return (
    <div className="py-0 px-8">
      <div className="flex flex-col justify-center px-8 w-full">
        <nav className="relative flex items-center justify-between w-full max-w-2xl pt-8 pb-8 mx-auto text-gray-900 border-gray-200 dark:border-gray-700 sm:pb-16">
          <span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
            Current theme: {resolvedTheme}
          </span>
          <ToggleButton
            onClick={() =>
              setTheme(resolvedTheme === "dark" ? "light" : "dark")
            }
            selectedTheme={resolvedTheme}
          />
        </nav>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Fix hydration errors

If you now try to yarn dev your app you’ll get a Hydration error:

Hydration errror

In order for hydration to function correctly, the initial client-side rendered version of the UI must be identical to the server-side one. However, in this case, the resolvedTheme variable is undefined on the server and a string on the client, causing the React trees rendered on the server and client to be different. As a result, hydration fails.

To fix this we need to ensure we don’t consume the resolvedTheme variable in the first component render:

// pages/index.tsx

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";

export default function Home() {
  const [mounted, setMounted] = useState(false);
  const { resolvedTheme, setTheme } = useTheme();

  useEffect(() => setMounted(true));

  return (
    <div className="py-0 px-8">
      <div className="flex flex-col justify-center px-8 w-full">
        <nav className="relative flex items-center justify-between w-full max-w-2xl pt-8 pb-8 mx-auto text-gray-900 border-gray-200 dark:border-gray-700 sm:pb-16">
          <span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
            Current theme: {mounted && resolvedTheme}
          </span>
          <ToggleButton
            onClick={() =>
              setTheme(resolvedTheme === "dark" ? "light" : "dark")
            }
            selectedTheme={resolvedTheme}
          />
        </nav>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this case, we are using the value of the mounted state to ensure that our component has been rendered client side at least once before attempting to access the resolvedTheme variable. At this moment the React trees on both client and server look like this:

// server
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
   Current theme: undefined
</span>

// client
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
   Current theme: undefined
</span>
Enter fullscreen mode Exit fullscreen mode

Rather than this, which was what cause the error :

// server
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
   Current theme: undefined
</span>

// client
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
   Current theme: string
</span>
Enter fullscreen mode Exit fullscreen mode

By doing this, we can ensure that hydration works properly because the React trees are identical.

  • Styling components with the dark: prefix

As you’ve seen in previous code examples, you can now use the dark: prefix with any Tailwind class to specify a variant that will only be applied when dark mode is active. For example, in the code below, the background color of a card grid will change based on the current theme:

const ProductsGrid = () => {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 ">
      {products.map((product) => (
        <div
          key={product.id}
          className="bg-white border hover:shadow-sm transition-all cursor-pointer border-gray-50 rounded-lg p-6 dark:bg-gray-800 dark:border-gray-600"
        >
          ...
        </div>
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Setting up extra themes

In order to add multiple themes to our Next.js app, you need to provide a list of options. Each theme option must map to a CSS attribute selector that updates global CSS variables, which in turn should update the values of custom Tailwind classes. This leads to changes in the colors of the custom classes used throughout your app.

You need to manipulate three files, _app.tsx, globals.css, and tailwind.config.js

  • In the _app.tsx file, remove attribute=’class’ as we are no longer using tailwindCSS's built-in dark mode:
// _app.tsx

import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider>
      <Component {...pageProps} />
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

After making the change, instead of the html element having a class attribute with a value of "dark" or "light", it will have a data-theme attribute with a value of "dark" or "any theme" (or any other value you have chosen for your themes).

  • In the globals.css file, we will set up attribute selectors for each theme option. This will allow all of the global CSS variables to update automatically when the useTheme function updates the data-theme attribute. For example:
/* globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

[data-theme="dark"] {
  /* dark theme styles go here */
}

[data-theme="gruvbox"] {
  /* gruvbox theme styles go here */
}

[data-theme="pink"] {
  /* pink theme styles go here */
}
Enter fullscreen mode Exit fullscreen mode
  • In the tailwind.config.js file, we will set darkMode to false and define custom classes that use the CSS variables we just defined:
// tailwind.config.js

module.exports = {
  darkMode: false,
  theme: {
    extend: {
      /* custom classes go here */
    },
  },
  variants: {},
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

By setting darkMode to false, we are disabling tailwindCSS's built-in dark mode. You can then define custom classes in the theme.extend object that use the CSS variables we defined in the globals.css file to control the styling of our elements.

  • Now you need to provide the user with a way to change the current theme:
// pages/index.tsx

const themes = [
  { name: "Light" },
  { name: "Dark" },
  { name: "Monakai" },
  { name: "Pink" },
];

const ToggleButton = () => {
  const [mounted, setMounted] = useState(false);
  const { theme, setTheme } = useTheme();

  // When mounted on client, now we can show the UI
  useEffect(() => setMounted(true), []);

  if (!mounted) return null;

  return (
    <>
      <span className="p-1 sm:px-3 sm:py-2 text-th-secodary">
        Current theme: {mounted && theme}
      </span>
      <div>
        <label htmlFor="theme-select" className="sr-only mr-2">
          Choose theme:
        </label>
        <select
          name="theme"
          id="theme-select"
          className="bg-white text-gray-800 border-gray-800 border py-1 px-3"
          onChange={(e) => setTheme(e.currentTarget.value)}
          value={theme}
        >
          <option value="system">System</option>
          {themes.map((t) => (
            <option key={t.name.toLowerCase()} value={t.name.toLowerCase()}>
              {t.name}
            </option>
          ))}
        </select>
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • That’s it! Now you can start applying your new custom classes that change their color values based on the current theme:
// pages/index.tsx

const ProductsGrid = () => {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 ">
      {generateProducts().map((product) => (
        <div
          key={product.id}
          className="bg-white border hover:shadow-sm transition-all cursor-pointer border-gray-50 rounded-lg p-6 dark:bg-gray-800 dark:border-gray-600"
        >
          <div className="relative w-full h-80">
            <Image
              src={product.imageUrl}
              alt={product.title}
              placeholder="blur"
              blurDataURL={`data:image/svg+xml;base64,${toBase64(
                shimmer(700, 475)
              )}`}
              fill
              sizes="100vw"
              className="object-cover"
            />
          </div>
          <h2 className="font-bold text-xl mt-2 text-gray-800">
            {product.title}
          </h2>
          <p className="text-th-accent text-sm">{product.subtitle}</p>
          <p className="text-gray-700 dark:text-gray-300 mt-2">
            {product.description}
          </p>
          <div className="flex space-x-2 mt-4">
            <button className="py-2 px-4 rounded-full bg-th-accent font-bold dark:bg-blue-800">
              Buy
            </button>
            <button className="p-1 h-9 w-9 flex items-center justify-center rounded-full bg-gray-200 text-gray-700 font-bold ">
              <FiShoppingCart />
            </button>
          </div>
        </div>
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

End result

Conclusion

Creating a custom dark mode and multiple themes is a great way to give users a more personalized and tailored experience. With the help of Tailwind's built-in dark mode, CSS variables, and custom classes, it's easy to create a dynamic, lightweight theme system that changes colors and styles on the fly. This article provided a step-by-step tutorial on how to create such a system, as well as a link to the repository that contains all the code used for the provided examples.

References

Latest comments (1)

Collapse
 
artu_hnrq profile image
Arthur Henrique

Hey Luis, nice post! 👏 Thanks for sharing that
I got curious, though, about the mentioned (but not presented) tailwind.config.js theme extend custom classes.

Would one be able to configure something similar to tailwind's dark: modification directive for each defined theme, for example?