Light, Dark and System Modes in Next.js with TailwindCSS

The complete pattern for dark mode is to allow users to choose between light, dark and system modes. System mode means that the website will automatically switch between light and dark mode based on the system settings. This article will show you how to implement this pattern in Next.js with TailwindCSS.

Set Up Next.js with TailwindCSS

First, we need to set up Next.js with TailwindCSS. You can follow the official guide here.

Note when you have to choose "Would you like to use Tailwind CSS? No / Yes" in the guide, choose "Yes".

Then you will get the document structure like this (App Route):

1.
2├── app
3│ ├── layout.js
4│ ├── page.js
5│ └── global.css

Integrate next-themes with TailwindCSS

Install next-themes

We need to install next-themes to help us switch between light, dark and system modes. You can install it by running the following command:

1npm install next-themes

Chanage darkMode to class

Go to tailwind.config.js and change darkMode to class:

1module.exports = {
2 darkMode: "class",
3};

This will allow us to use the dark class to switch between light and dark mode. Specifically, when <html> tag has the dark class, the website will be in dark mode and you can style it with the dark: prefix by TailwindCSS.

Wrap the App with ThemeProvider

Create a file called provider.tsx in the app folder and add the following code:

1"use client";
2
3import { ThemeProvider } from "next-themes";
4
5export function Providers({ children }: { children: React.ReactNode }) {
6 return <ThemeProvider attribute="class">{children}</ThemeProvider>;
7}

Note you should let attribute be class so that we can use the dark class to switch between light and dark mode.

Then, wrap the App with Providers in app/layout.tsx:

1import { Providers } from "./providers";
2
3function RootLayout({ children }: { children: React.ReactNode }) {
4 return (
5 <html lang="en" suppressHydrationWarning>
6 <body className="min-h-screen dark:bg-slate-900">
7 <Providers>
8 <main className="px-6 md:px-16 max-w-5xl mx-auto">{children}</main>
9 </Providers>
10 </body>
11 </html>
12 );
13}

Add a Button to Switch Between Light, Dark and System Modes

Create components folder inside the app folder and create a file called ThemeMenu.tsx inside the components folder.

Use setTheme() from next-themes to switch between light, dark and system modes.

1const { theme, setTheme } = useTheme();

Then, add a button to switch between light, dark and system modes, for example:

1import { useTheme } from "next-themes";
2
3const ThemeChanger = () => {
4 const { theme, setTheme } = useTheme();
5
6 return (
7 <div>
8 The current theme is: {theme}
9 <button onClick={() => setTheme("light")}>Light Mode</button>
10 <button onClick={() => setTheme("dark")}>Dark Mode</button>
11 </div>
12 );
13};

The above code is hydration unsafe and will throw a hydration mismatch warning when rendering with SSG or SSR. This is because we cannot know the theme on the server, so it will always be undefined until mounted on the client.

To fix this, check the document of next-themes here.

I created the theme switch menu with Headless UI and TailwindCSS. Here is the code:

1import { Menu, Transition } from "@headlessui/react";
2import { Fragment, useEffect, useState } from "react";
3import { SunIcon, MoonIcon, CogIcon } from "@heroicons/react/24/solid";
4import { useTheme } from "next-themes";
5import { ReactElement } from "react";
6
7export default function ThemeMenu() {
8 const [mounted, setMounted] = useState(false);
9 const { theme, setTheme } = useTheme();
10
11 useEffect(() => {
12 setMounted(true);
13 }, []);
14
15 return (
16 <Menu as="div" className="relative inline-block text-left">
17 <div className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-gray-100 dark:hover:bg-slate-800">
18 <Menu.Button className="w-6 h-6">
19 {mounted ? (
20 <div>
21 <SunIcon id="header__light" />
22 <MoonIcon id="header__dark" className="p-0.5" />
23 </div>
24 ) : (
25 // when unmounted, render an empty div to occupy the space
26 <div />
27 )}
28 </Menu.Button>
29 </div>
30 <Transition
31 as={Fragment}
32 enter="transition ease-out duration-100"
33 enterFrom="transform opacity-0 scale-95"
34 enterTo="transform opacity-100 scale-100"
35 leave="transition ease-in duration-75"
36 leaveFrom="transform opacity-100 scale-100"
37 leaveTo="transform opacity-0 scale-95"
38 >
39 <Menu.Items className="absolute right-0 mt-2 w-28 origin-top-right rounded-md bg-white dark:bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
40 <div className="px-1 py-1 ">
41 <ThemeMenuItem
42 theme="light"
43 icon={<SunIcon className="h-5 w-5" />}
44 label="Light"
45 setTheme={setTheme}
46 selected={theme === "light"}
47 />
48 <ThemeMenuItem
49 theme="dark"
50 icon={<MoonIcon className="h-4 w-4" />}
51 label="Dark"
52 setTheme={setTheme}
53 selected={theme === "dark"}
54 />
55 <ThemeMenuItem
56 theme="system"
57 icon={<CogIcon className="h-5 w-5" />}
58 label="System"
59 setTheme={setTheme}
60 selected={theme === "system"}
61 />
62 </div>
63 </Menu.Items>
64 </Transition>
65 </Menu>
66 );
67}
68
69function ThemeMenuItem({
70 theme,
71 icon,
72 label,
73 setTheme,
74 selected,
75}: {
76 theme: string;
77 icon: ReactElement;
78 label: string;
79 setTheme: (theme: string) => void;
80 selected: boolean;
81}) {
82 return (
83 <Menu.Item>
84 {({ active }) => (
85 <button
86 className={generateClassName(active, selected)}
87 onClick={() => setTheme(theme)}
88 >
89 <div className="flex items-center justify-center h-5 w-5 mr-2">
90 {icon}
91 </div>
92 {label}
93 </button>
94 )}
95 </Menu.Item>
96 );
97}
98
99function generateClassName(active: boolean, selected: boolean) {
100 let baseClass =
101 "group flex w-full items-center rounded-md px-2 py-1.5 my-0.5";
102 if (active) {
103 baseClass = `${baseClass} bg-gray-100 dark:bg-slate-600`;
104 }
105 if (selected) {
106 baseClass = `${baseClass} ring-1 ring-orange-500 ring-opacity-60`;
107 }
108 return baseClass;
109}

With the CSS code in global.css:

1#header__light,
2#header__dark {
3 display: none;
4}
5
6html[class="dark"] #header__dark {
7 display: block;
8}
9
10html[class="light"] #header__light {
11 display: block;
12}

This piece of code is used to show the corresponding icon according to the current theme. When user choose the system mode, the icon will be his system theme on his device, which is determined by prefers-color-scheme.

Now you can switch between light, dark and system modes by this menu button. You only need to add dark: prefix to the class name to make the dark mode style work.