Add a dark/light mode selector to your Next.js project

Add a dark/light mode selector to your Next.js project

User experience is critical to determining a website's or web application's success. One way to enhance it is to let users personalize their browsing experience with theme customization. Enabling theme switching allows users to select a color scheme that reflects their preferences, creating a more engaging and pleasant interaction with your app.

React frameworks like Next.js make building robust and responsive web applications more accessible than ever. Next.js offers server-side rendering, automatic code splitting, server actions, and optimized performance. In this guide, we'll leverage some of the features of Next.js, Tailwind CSS, and the next-themes package to add a dark/light mode toggle component to our project that respects system preference.

This article assumes you know how to set up a Next.js project with typescript and Tailwind CSS. If you don't, check out my article Setting Up Your First Next.js Project.

The Problem

Next.js features server-side rendering (SSR), which means that the server prerenders pages before sending them to the browser, which is a plus for speed and optimization. But for our purposes, it's a bit of a drawback. The browser stores the color scheme for our app in Window:localStorage. The server doesn't have access to the Window object, so it can't know whether the user has chosen a light or dark theme. But don't worry. This article will teach you the perfect workaround for seamless theme switching with SSR.

Once the component is up and running, the user's preference will persist the next time they visit the site, regardless of their system setting.

Getting set up

The first thing we need to do is install a few dependencies:

HeadlessUI will give us a listbox component, which will be the UI for the selector.

$ npm i @headlessui/react

HeroIcons allows us to use some cool-looking icons in our listbox.

$ npm install @heroicons/react

Next-Themes is "an abstraction for themes in your Next.js app."

$ npm install next-themes

Now, open up tailwind.config.ts and add darkMode: "selector" to the top of the config function.

import type { Config } from "tailwindcss";

const config: Config = {
	darkMode: "selector",
	content: [
		"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
		"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
		"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
	],
	theme: {},
	plugins: [],
};
export default config;

Head over to globals.css and change the :root selectors to light and dark, respectively.

.light {
  --foreground-rgb: 0, 0, 0;
  --background-start-rgb: 214, 219, 220;
  --background-end-rgb: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
  .dark {
    --foreground-rgb: 255, 255, 255;
    --background-start-rgb: 0, 0, 0;
    --background-end-rgb: 0, 0, 0;
  }
}

The app now supports manual toggling of dark mode and respects the user's system preferences. Go ahead and test it by changing your system preferences.

Theme Provider

Now, we're going to create our theme provider.

Next-themes' theme provider gives us access to localStorage a client-side browser object that stores the theme. Next.js renders everything on the server unless you tell it not to. So, we need to create a provider with the "use client" directive to wrap around our server-rendered layout.

Create a new folder called providers in the src directory. Then, create a new file called ThemeProvider.tsx. At the top of the page, add "use client" before the import statements.

'use client'

Then, import ThemeProvider from next-themes.

"use client";

import { ThemeProvider } from "next-themes";

Next, we'll cast the {children} parameter as a RectNode, so we don't have to use the any type, which can make our code more error-prone.

"use client";

import { ThemeProvider } from "next-themes";

interface ProviderProps {
	children: React.ReactNode;
}

Lastly, we'll create a function called Provider for our layout.tsx file.

"use client";

import { ThemeProvider } from "next-themes";

interface ProviderProps {
	children: React.ReactNode;
}

export default function Provider({ children }: ProviderProps) {
	return <ThemeProvider attribute='class'>{children}</ThemeProvider>;
}

Head over to the layout.tsx file, import the provider function, and wrap the {children} with it. Now, we'll be able to change the color scheme of our app, and the change will persist between sessions. Please note that we have to add suppressHydrationWarning to the <html> tag to avoid hydration mismatch warnings.

export default function RootLayout({
	children,
}: Readonly<{
	children: React.ReactNode;
}>) {
	return (
		<html lang='en' suppressHydrationWarning>
			<body className={inter.className}>
				<Provider>{children}</Provider>
			</body>
		</html>
	);
}

The Component

Our component will use the ListBox from HeadlessUI. Developed by the Tailwind team, HeadlessUI is a small set of "completely unstyled, fully accessible UI components, designed to integrate beautifully with Tailwind CSS." If you've never used it, you should visit the website and check it out.

We will also use another Tailwind product called HeroIcons, a nice set of free and open-source SVG icons.

Let's start by creating a components folder and adding a file called DarkModeSelector.tsx.

Since we'll be using useEffect and State, we'll add the "use client" directive at the top of the document.

Next, we'll export a function , and we'll name it DarkModeSelect.

"use client";

export default function DarkModeSelector() {
  return ()
};

Now, we need to import in ListBox and Transition from HeadlessUI, useState from React and useTheme from next-themes. The ListBox uses Listbox.Button, Listbox.Options, Listbox.Option and Listbox.Label components.

ListBox.Option exposes two props, active and selected, that we'll use to determine the mouse or keyboard focus and the user's selection through useState.

We use the Transition component to animate the ListBox entrance and exit. The show prop allows us to conditionally show and hide its children, which, in this case, is the body of the ListBox. The rest of the props will enable us to add Tailwind utility classes for transitions.

Now we have a bare-bones ListBox component that doesn't do anything except throw errors. That's because we haven't given it any data to consume. So, let's fix that!

"use client";

import { useState } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { useTheme } from "next-themes";

export default function DarkModeSelector() {
  return (
     <Listbox value={} onChange={}>
     {({ open }) => (
      <Listbox.Button></Listbox.Button>
      <Transition
          show={open}
          enter='transition ease-out duration-100'
          enterFrom='opacity-0'
          enterTo='opacity-100'
          leave='transition ease-in duration-100'
          leaveFrom='opacity-100'
          leaveTo='opacity-0'
		>
       <Listbox.Options>
            <Listbox.Option key={} value={}>
            </Listbox.Option>
        </Listbox.Options>
      </Transition>
    </Listbox>
  )
}

Here, we'll import the HeroIcons to use in the ListBox.Button and the ListBox.Options,. We use them like any other React component. Make sure you append "Icon" in camelcase after the name.

import {
	SunIcon,
	MoonIcon,
	ComputerDesktopIcon,
} from "@heroicons/react/20/solid";

Then, we create an array of objects that includes the icons and their names with an id for the key. We cast the array members as type ThemeOption.

type ThemeOption = {
	id: string;
	name: string;
	icon: React.ReactNode;
};

const themeOptions: ThemeOption[] = [
	{
		id: "light",
		name: "Light",
		icon: <SunIcon className='size-5' />,
	},
	{
		id: "dark",
		name: "Dark",
		icon: <MoonIcon className='size-5' />,
	},
	{
		id: "system",
		name: "System",
		icon: <ComputerDesktopIcon className='size-5' />,
	},
];

Next, we map through the array.

export default function DarkModeSelector() {
  return (
     <Listbox value={} onChange={}>
     {({ open }) => (
      <Listbox.Button>
        <SunIcon/>
        <MoonIcon/>
      </Listbox.Button>
      <Transition
          show={open}
          enter='transition ease-out duration-100'
          enterFrom='opacity-0'
          enterTo='opacity-100'
          leave='transition ease-in duration-100'
          leaveFrom='opacity-100'
          leaveTo='opacity-0'
		>
       <Listbox.Options>
        {themeOptions.map((themeOption) => (
            <Listbox.Option 
              key={themeOption.id} 
              className={}
              value={themeOption}>
            >
              {themeOption.icon}
              {themeOption.name}
            </Listbox.Option>
          ))}
        </Listbox.Options>
      </Transition>
    </Listbox>
  )

Let's add this function under the import statements at the top of the page. Tailwind enables you to write conditional styles using this pattern, and we'll need this functionality shortly.

function classNames(...classes: string[]) {
	return classes.filter(Boolean).join(" ");
}

In the following few sections, we'll add the rest of the styling and integrate the selected prop into the useTheme hook.

Under the primary function declaration, we'll set selected as either the ThemeOption type or null, with the initial state being null. We'll also call the useTheme hook by destructuring setTheme.

const [selected, setSelected] = useState<ThemeOption | null>(null);
const { setTheme } = useTheme();

Now, we'll write a useEffect to handle theme switching based on the state of selected. We'll use optional chaining here because selected can be null. So, for example, if selected.id is light, next-themes will set the theme to light, and the line on the dropdown with the sun icon will be highlighted.

useEffect(() => {
		if (selected?.id === "system") {
			setTheme("system");
			setSelected(themeOptions[2]);
		} else if (selected?.id === "dark") {
			setTheme("dark");
			setSelected(themeOptions[1]);
		} else if (selected?.id === "light") {
			setTheme("light");
			setSelected(themeOptions[0]);
		}
	}, [selected, setTheme]);

It's time to set up our conditional styles.

You can use a ternary operator to apply styles to active and selected elements. The active modifier Is the hover state of each option. An element becomes selected when a user clicks on it.

<Listbox value={selected} onChange={setSelected}>
    {({ open }) => (
        <div className='relative'>
            <Listbox.Button className='flex items-center'>
                <MoonIcon
                    className='size-5 text-gray-900 dark:text-gray-50 hidden dark:inline'
                    aria-hidden='true'
                />
  
                <SunIcon
                    className='size-5 text-gray-900 dark:text-gray-50 dark:hidden'
                    aria-hidden='true'
                />
            </Listbox.Button>
  
            <Transition
                show={open}
                enter='transition ease-out duration-100'
                enterFrom='opacity-0'
                enterTo='opacity-100'
                leave='transition ease-in duration-100'
                leaveFrom='opacity-100'
                leaveTo='opacity-0'
            >
                <Listbox.Options className='absolute z-50 top-full list-none right-0 bg-white rounded-lg ring-1 ring-slate-900/10 shadow-lg overflow-hidden w-36 py-1 text-sm dark:bg-slate-800 dark:ring-0 mt-8'>
                    {themeOptions.map((themeOption) => (
                        <Listbox.Option
                            key={themeOption.id}
                            className={({ active, selected }) =>
                                classNames(
                                    active
                                        ? "bg-gray-600/10 dark:bg-gray-600/30 text-purple-600 dark:text-purple-300"
                                        : "text-gray-900 dark:text-gray-50",
                                    selected
                                        ? "text-purple-600 dark:text-purple-300"
                                        : "text-gray-900 dark:text-gray-50",
                                    "relative cursor-default py-2 pl-3 pr-9 ml-0"
                                )
                            }
                            value={themeOption}
                        >
                            {({ selected }) => (
                                <div className='py-1 px-2 flex items-center cursor-pointer'>
                                    <span className='flex-shrink-0 mr-4 font-semibold'>
                                        {themeOption.icon}
                                    </span>
                                    <span
                                        className={classNames(
                                            selected ? "font-semibold" : "font-normal",
                                            "ml-3 block"
                                        )}
                                    >
                                        {themeOption.name}
                                    </span>
                                </div>
                            )}
                        </Listbox.Option>
                    ))}
                </Listbox.Options>
            </Transition>
        </div>
    )}
  </Listbox>

Since the server doesn't know what color scheme the user will pick, some of the values returned by useTheme will be undefined until our component is mounted on the client. If you try to render the page based on the current theme before mounting on the client, you'll see a hydration mismatch error. So, we'll add a little function that will only render UI that uses the current color scheme after the page is mounted.

The first thing is to set up a useState hook like this:

const [mounted, setMounted] = useState(false);

And then write the following useEffect:

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

	if (!mounted) {
		return null;
	} else {
		return
      //the rest of your code

Here's the complete component:

"use client";

import { useState, useEffect } from "react";
import {
	SunIcon,
	MoonIcon,
	ComputerDesktopIcon,
} from "@heroicons/react/20/solid";
import { Listbox, Transition } from "@headlessui/react";
import { useTheme } from "next-themes";

function classNames(...classes: string[]) {
	return classes.filter(Boolean).join(" ");
}

type ThemeOption = {
	id: string;
	name: string;
	icon: React.ReactNode;
};

const themeOptions: ThemeOption[] = [
	{
		id: "light",
		name: "Light",
		icon: <SunIcon className='size-5' />,
	},
	{
		id: "dark",
		name: "Dark",
		icon: <MoonIcon className='size-5' />,
	},
	{
		id: "system",
		name: "System",
		icon: <ComputerDesktopIcon className='size-5' />,
	},
];

export default function DarkModeSelector() {
	const [selected, setSelected] = useState<ThemeOption | null>(null);
	const { setTheme } = useTheme();
	const [mounted, setMounted] = useState(false);

	useEffect(() => {
		if (selected?.id === "system") {
			setTheme("system");
			setSelected(themeOptions[2]);
		} else if (selected?.id === "dark") {
			setTheme("dark");
			setSelected(themeOptions[1]);
		} else if (selected?.id === "light") {
			setTheme("light");
			setSelected(themeOptions[0]);
		}
	}, [selected, setTheme]);

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

	if (!mounted) {
		return null;
	} else {
		return (
			<Listbox value={selected} onChange={setSelected}>
				{({ open }) => (
					<div className='relative'>
						<Listbox.Button className='flex items-center'>
							<MoonIcon
								className='size-5 text-gray-900 dark:text-gray-50 hidden dark:inline'
								aria-hidden='true'
							/>

							<SunIcon
								className='size-5 text-gray-900 dark:text-gray-50 dark:hidden'
								aria-hidden='true'
							/>
						</Listbox.Button>

						<Transition
							show={open}
							enter='transition ease-out duration-100'
							enterFrom='opacity-0'
							enterTo='opacity-100'
							leave='transition ease-in duration-100'
							leaveFrom='opacity-100'
							leaveTo='opacity-0'
						>
							<Listbox.Options className='absolute z-50 top-full list-none right-0 bg-white rounded-lg ring-1 ring-slate-900/10 shadow-lg overflow-hidden w-36 py-1 text-sm dark:bg-slate-800 dark:ring-0 mt-8'>
								{themeOptions.map((themeOption) => (
									<Listbox.Option
										key={themeOption.id}
										className={({ active, selected }) =>
											classNames(
												active
													? "bg-gray-600/10 dark:bg-gray-600/30 text-purple-600 dark:text-purple-300"
													: "text-gray-900 dark:text-gray-50",
												selected
													? "text-purple-600 dark:text-purple-300"
													: "text-gray-900 dark:text-gray-50",
												"relative cursor-default py-2 pl-3 pr-9 ml-0"
											)
										}
										value={themeOption}
									>
										{({ selected }) => (
											<div className='py-1 px-2 flex items-center cursor-pointer'>
												<span className='flex-shrink-0 mr-4 font-semibold'>
													{themeOption.icon}
												</span>
												<span
													className={classNames(
														selected ? "font-semibold" : "font-normal",
														"ml-3 block"
													)}
												>
													{themeOption.name}
												</span>
											</div>
										)}
									</Listbox.Option>
								))}
							</Listbox.Options>
						</Transition>
					</div>
				)}
			</Listbox>
		);
	}
}

That's it! You now have a dark or light mode selector that respects the system settings and works on the Next.js app router. Using the app router version of Next.js is a paradigm shift, but once you get used to it, it's a million times easier than the page router. Building client-side components requires a few more steps, but you'll have faster, better-optimized, and more robust applications in the long run.

I hope you found this tutorial helpful. Please don't hesitate to share your thoughts and ideas with me. You can reach me on Twitter or my other social media by clicking one of the links in the footer.