How to implement light & dark mode in Next.js 14 with Tailwind (2 methods)

June 9th, 20245 mins read

How to implement light & dark mode in Next.js 14 with Tailwind (2 methods)

Next.js + Tailwind CSS makes it easy to implement dark mode using the dark variant. After basic configurations, you can prepend dark: to properties to indicate that they are for dark mode.

For example, if you are styling a paragraph and want the text to have the white color in dark mode and black in light mode. You’d do this:

<p className="text-white dark:text-black">Hello world!</p>

This only works if you have added darkMode: 'class' in your tailwind.config.js file:

// tailwind.config.ts
/** @type {import('tailwindcss').Config} */
module.exports = {
	darkMode: 'class',
	// ...
};

Tailwind does not include dark mode styles by default, so you must enable it explicitly. It tells Tailwind CSS to use a CSS class to apply dark mode styles.

Also, using the class strategy allows you to control dark mode by toggling a class (dark) on the root element (usually the <html> or <body> tag). This allows you to programmatically add or remove the dark class based on user actions or preferences.

Next, let’s implement the dark mode feature and switch in your Next.js project.

There are two major ways: using a package that makes it easier or writing some boilerplate code, which may not always be fun. This guide explains both methods. Let’s get right into it!

Method 1: Using the next-themes package

The next-themes package makes managing themes in a Next.js project very easy.

It adapts to the user's system theme configuration, reduces the code you need to write, and handles theme persistence automatically.

To get started, install the package by running the command below:

npm i next-themes

Next, create a providers.tsx file inside your app folder and add the following code to initialize and configure next-themes to manage theme switching in your Next.js application:

'use client';
import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
	return (
		<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
			{children}
		</ThemeProvider>
	);
}

This code imports ThemeProvider from next-themes, and the Providers component wraps the entire application, enabling theme management with CSS class toggling on the HTML element.

The configuration sets the default theme to match the user's system preference and allows automatic theme switching based on system settings, ensuring consistent and responsive theme management throughout the app.

Next, add the <Providers> component to your root layout.tsx by placing it inside the <body> tag:

import { Providers } from './providers';

export default function RootLayout({
	children,
}: {
	children: React.ReactNode;
}) {
	return (
		<html lang="en" suppressHydrationWarning>
			<body>
				<Providers>{children}</Providers>
			</body>
		</html>
	);
}

Notice the suppressHydrationWarning setting inside the <html> tag. This prevents hydration warnings because the html element is updated by next-themes.

Next, you can add styles that differentiate your project's dark and light modes. For example, you can style the general page via the <body> tag in the layout.tsx file:

import { Providers } from './providers';

export default function RootLayout({
	children,
}: {
	children: React.ReactNode;
}) {
	return (
		<html lang="en" suppressHydrationWarning>
			<body className={`bg-lightbg text-black dark:bg-darkbg dark:text-white`}>
				<Providers>{children}</Providers>
			</body>
		</html>
	);
}

Finally, create a component to toggle between light and dark modes:

'use client';

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

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

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

	if (!mounted) {
		return null;
	}

	return (
		<div className="bg-background text-primary-green">
			The current theme is: {theme}
			<br />
			<button onClick={() => setTheme('light')}>Light Mode</button>
			<br />
			<button onClick={() => setTheme('dark')}>Dark Mode</button>
		</div>
	);
};

export default ThemeSwitcher;

This component tracks its mounted state to avoid hydration mismatch warnings. When mounted, it renders a div displaying the current theme and provides buttons to switch between light and dark modes by calling setTheme.

Method 2: Implement light & dark mode from scratch

If you prefer not to use external packages, you can manage dark mode manually with some custom boilerplate code.

To do this, ensure you have added darkMode: 'class' in your tailwind.config.js file.

Next, create a component that will handle the entire logic and add the following code:

'use client';
import { useState, useEffect, use } from 'react';

const ThemeToggle = () => {
	const [darkTheme, setDarkTheme] = useState(true);

	useEffect(() => {
		const theme = localStorage.getItem('theme');
		if (theme === 'dark') {
			setDarkTheme(true);
		} else {
			setDarkTheme(false);
		}
	}, []);

	useEffect(() => {
		if (darkTheme) {
			document.documentElement.classList.add('dark');
			localStorage.setItem('theme', 'dark');
		} else {
			document.documentElement.classList.remove('dark');
			localStorage.setItem('theme', 'light');
		}
	}, [darkTheme]);

	return (
		<div>
			<button onClick={() => setDarkTheme(!darkTheme)}>Toggle Theme</button>
		</div>
	);
};

export default ThemeToggle;

Let’s break down the code above and explain it in bits.

In the ThemeToggle component, we use the state hook to manage the dark theme state, determining whether the dark mode is enabled.

const [darkTheme, setDarkTheme] = useState(true);

By default, we set it to true, assuming dark mode is on initially. Next, we use the useEffect hook to check the user's previously saved theme from local storage when the component mounts:

useEffect(() => {
	const theme = localStorage.getItem('theme');
	if (theme === 'dark') {
		setDarkTheme(true);
	} else {
		setDarkTheme(false);
	}
}, []);

This effect runs once when the component mounts, retrieving the theme preference from local storage. If the saved theme is 'dark', we set darkTheme to true; otherwise, we set it to false. This ensures the user's theme preference is respected when they revisit the site.

Another useEffect hook listens for changes to the darkTheme state. Whenever it changes, this effect updates the html element's class to either add or remove the 'dark' class and saves the current theme to local storage:

useEffect(() => {
	if (darkTheme) {
		document.documentElement.classList.add('dark');
		localStorage.setItem('theme', 'dark');
	} else {
		document.documentElement.classList.remove('dark');
		localStorage.setItem('theme', 'light');
	}
}, [darkTheme]);

This ensures that the UI updates reflect the current theme and that the theme preference is saved for future visits. Finally, we provide a simple button that toggles the theme when clicked:

return (
	<div>
		<button onClick={() => setDarkTheme(!darkTheme)}>Toggle Theme</button>
	</div>
);

The button toggles the darkTheme state, switching between light and dark modes.

Wrapping up!

We've explored two methods for implementing light and dark modes in a Next.js application using Tailwind CSS.

The next-themes package offers a quick and efficient way to manage themes, automatically adapts to system settings, and requires minimal code. It's ideal if you want a reliable, hassle-free setup that seamlessly integrates with Tailwind CSS.

Choose next-themes for ease and reliability or a custom solution for flexibility and control. Both methods ensure a smooth light and dark mode experience for your users.