bg-image
web
Add Multiple Theme To Next.JS and Tailwind Project
author-avatar
Patrick Xin
June 6, 2022 - 6 minutes read

Switch themes easily with few lines of code.

Add Multiple Theme To Next.JS and Tailwind Project-image
Image generated by Midjourney

Intro

Nowadays we have a lot of projects that need to be styled with Tailwind. In some cases, you may need to add multiple themes to your project. In today's article, I will show you how easy it is to do so with a few setups. The easiest way is to use next-themes.

🌱 Checkout deployed version to see the what final result looks like. Source code can be found here

Crate a New Project

Initialize a new Next.JS project with Tailwind by running npx create-next-app with-tailwind multiple-themes.

Add Theme Provider

pages/_app.tsx
import '../styles/globals.css'
import { ThemeProvider } from 'next-themes'
import type { AppProps } from 'next/app'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider enableSystem={false} disableTransitionOnChange>
      <Component {...pageProps} />
    </ThemeProvider>
  )
}

export default MyApp

Configure Tailwind CSS

Add CSS Variables

We need to define CSS variables inside globals.css file so that Tailwind knows how to access them.

globals.css
@layer base {
  :root {
    --bg-color: #f4f4f0;
    --text-color: #212121;
  }

  [data-theme='dark'] {
    --bg-color: #212121;
    --text-color: #f4f4f0;
  }

  [data-theme='yellow'] {
    --bg-color: #ffc300;
    --text-color: #f4f4f0;
  }

  [data-theme='purple'] {
    --bg-color: #39009e;
    --text-color: #15b2fb;
  }

  body {
    @apply relative text-pageText bg-pageBG;
  }
}

These are arbitrary CSS variables that I created, feel free to change them based on your taste. Don't forget to apply those variables to the body element.

Add CSS Variables to Tailwind

Add the highlighted lines to your tailwind.config.js file.

tailwind.config.js
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        BackgroundColor: "var(--bg-color)",
        TextColor: "var(--text-color)",
      },
    },
  },
  plugins: [],
};

With current configuration, we are ready to change themes.

Define Theme Object

In order to switch between themes, we need to create a theme object which contains the themes that our website can use.

constants/theme.tsx
export const themes: ITheme[] = [
  {
    title: "Light",
    name: "light",
    emoji: <span>🌞</span>,
  },
  {
    title: "Dark",
    name: "dark",
    emoji: <span>🌙</span>,
  },
  {
    title: "Yellow",
    name: "yellow",
    emoji: <span>🌼</span>,
  },
  {
    title: "Purple",
    name: "purple",
    emoji: <span>🦄</span>,
  },
];

As I'm using TypeScript, I've also created a ITheme interface. If you are unfamiliar with TypeScript, you can skip this type definition.

types/theme.ts
export type ThemeName = "light" | "dark" | "yellow" | "purple";
export type ThemeTitle = Capitalize<ThemeName>;

export interface ITheme {
  title: ThemeTitle;
  name: ThemeName;
  emoji: React.ReactNode;
}

For more type safty, we're using Template Literal Types to define the type of the title and name properties. title will be shown in the UI and name will be used to tell next-themes what current theme we are using.

Create ThemeSwitch Component

There are various ways create a switcher, you can either use a dropdown select or a button. I will create both just for fun.

ThemeSwtichButton Component

Let's create a switch button first.

components/ThemeSwtichButton.tsx
import React from "react";
import { useTheme } from "next-themes";
import { themes } from "../constants/theme";
import { useHasMounted } from "../hooks";

const ThemeSwitchButton = () => {
  const { theme, setTheme } = useTheme();
  const hasMounted = useHasMounted();
  if (!hasMounted || !theme) return null;

  const currentIndex = Math.abs(themes.findIndex((t) => t.name === theme));
  const currentTheme = themes[currentIndex];
  const nextTheme = themes[(currentIndex + 1) % themes.length];

  return (
    <div>
      <button
        className="border inline-flex gap-2 justify-center w-32 items-center border-TextColor px-4 py-2 rounded-md"
        onClick={() => setTheme(nextTheme.name)}
      >
        {currentTheme.emoji}
        <span>{currentTheme.name}</span>
      </button>
    </div>
  );
};

export default ThemeSwitchButton;

Notice we have a custom hook inside the component. We're using useHasMounted to make sure the component is mounted before we set the theme. The doc explains pretty well why we need this.

Because we cannot know the theme on the server, many of the values returned from useTheme will be undefined until mounted on the client. This means if you try to render UI based on the current theme before mounting on the client, you will see a hydration mismatch error.

Then we need to know what the current theme is by using useTheme and accessing the themes properties defined earlier(title, name and emoji). We're using findIndex to find the index of the current theme in the themes array. Then we use mod to get the next theme. Once the button is clicked, we use setTheme to change the theme.

ThemeSwitchSelect Component

I will use Headless UI Kit's Select component to create a dropdown select as it provide easy to use API and great accessibility. You can refer to the Headless UI Kit for more information.

components/ThemeSwtichSelect.tsx
import { useTheme } from "next-themes";
import React, { useEffect, useState } from "react";
import { Listbox } from "@headlessui/react";
import { CheckIcon } from "@heroicons/react/solid";
import { useHasMounted, useMultipleTheme } from "../hooks";
import { themes } from "../constants/theme";

const ThemeSwitchSelect = () => {
  const { theme, setTheme } = useTheme();
  const hasMounted = useHasMounted();
  if (!hasMounted || !theme) return null;

  const currentIndex = Math.abs(themes.findIndex((t) => t.name === theme));
  const currentTheme = themes[currentIndex];

  return (
    <div className="w-40">
      <Listbox
        value={theme}
        onChange={(theme) => {
          setTheme(theme);
        }}
      >
        <div className="relative border border-TextColor rounded-lg">
          <Listbox.Button className="relative w-full cursor-default rounded-lg py-2 pl-3 pr-10 inline-flex gap-6 shadow-md">
            {currentTheme.emoji}
            <span>{currentTheme.title}</span>
          </Listbox.Button>
          <Listbox.Options className="absolute max-h-60 w-full overflow-auto rounded-md py-1 text-base shadow-lg border-t-0 border rounded-t-none border-TextColor">
            {themes.map((theme) => (
              <Listbox.Option
                key={theme.name}
                value={theme.name}
                className={({ active }) =>
                  `relative cursor-pointer select-none py-2 pl-10 pr-4 ${
                    active ? "opacity-100" : "opacity-80"
                  }`
                }
              >
                {({ selected }) => (
                  <div className="inline-flex gap-4">
                    {theme.emoji}
                    <span
                      className={`block truncate ${
                        selected ? "font-bold" : "font-normal"
                      }`}
                    >
                      {theme.title}
                    </span>
                    {selected ? (
                      <span className="absolute inset-y-0 left-0 flex items-center pl-3">
                        <CheckIcon className="h-5 w-5" aria-hidden="true" />
                      </span>
                    ) : null}
                  </div>
                )}
              </Listbox.Option>
            ))}
          </Listbox.Options>
        </div>
      </Listbox>
    </div>
  );
};

export default ThemeSwitchSelect;

In the select component, we no longer need to calculate nextTheme as ListBox contains all the themes we need to use.

Let's render both components on the screen.

pages/index.tsx
import type { NextPage } from "next";

import ThemeSwitchSelect from "../components/ThemeSwitchSelect";
import ThemeSwitchButton from "../components/ThemeSwitchButton";

const Home: NextPage = () => {
  return (
    <div className="flex min-h-screen gap-6 flex-col items-center justify-center">
      <h1 className="text-3xl">Theme Switch</h1>
      <div className="flex gap-6">
        <ThemeSwitchSelect />
        <ThemeSwitchButton />
      </div>
    </div>
  );
};

export default Home;

Extract Logic

You may notice that we are reusing the same logic in both components, it's a good idea to use a custom hook to avoid duplicate code. We can create a custom hook called useMultipleTheme to handle multiple theme changes.

hooks/index.ts
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { ITheme } from "../types";

export const useMultipleTheme = (themes: ITheme[]) => {
  const { theme, setTheme } = useTheme();
  const currentIndex = Math.abs(themes.findIndex((t) => t.name === theme));
  const currentTheme = themes[currentIndex];
  const nextTheme = themes[(currentIndex + 1) % themes.length];

  return {
    theme,
    setTheme,
    currentTheme,
    nextTheme,
  };
};

In the component simply replace useTheme with useMultipleTheme and pass the themes array as an argument.

That's it for today, thanks for reading and happy coding!

next.js
tailwind
theme
react
Comments
Questions? Thoughts? Welcome to drop a comment below!
bg-image