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.