DEV Community

Cover image for Next.js + TailwindCSS v4: How to Add Dark/Light Theme with Next-Themes
Khan Rabiul
Khan Rabiul

Posted on

Next.js + TailwindCSS v4: How to Add Dark/Light Theme with Next-Themes

TailwindCSS v4 no longer maintains multiple config files-everything now goes inside a single global.css file. This can make theming feel a bit challenging. In this guide, I'll show you how to easily set up light, dark, and even custom themes in your Next.js project using next-themes.

Step 01: Initiate your project

👉 Install your Next.js project

pnpm create next-app my-project-name
pnpm install
pnpm dev
Enter fullscreen mode Exit fullscreen mode

👉 Install Next Themes

pnpm add next-themes
Enter fullscreen mode Exit fullscreen mode

Step 02: Modify your layout.tsx

👉 Wrap the application(children) with <ThemeProvider></ThemeProvider>. In the following example, I keep all the default Next.js code.

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "next-themes";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
       <ThemeProvider>
         {children}
       </ThemeProvider>
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

👉 Add attributes for ThemeProvider.

<ThemeProvider enableSystem={true} defaultTheme="system" >
{children}
</ThemeProvider>
Enter fullscreen mode Exit fullscreen mode

Attributes

enableSystem={true} ensures that your application can perform according to the user's device preference. By default, it is false.

defaultTheme="system" defines which theme will be loaded on the first load of the application.

error

A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:

Add suppressHydrationWarning attribute in html tag. It will solve the error.

<html lang="en" suppressHydrationWarning>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
       <ThemeProvider enableSystem={true} defaultTheme="system">
         {children}
       </ThemeProvider>
      </body>
    </html>
Enter fullscreen mode Exit fullscreen mode

Remember that ThemeProvider is not a server component. It is a client component.

Step 03: Customize your color

👉 global.css

Add them to your global.css

@import 'tailwindcss';

@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
Enter fullscreen mode Exit fullscreen mode

To work with tailwindcss v4, @import 'tailwindcss' is required.

@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)) line of code enables dark classes in HTML tags.

<div className="bg-gray-300 dark:bg-amber-500">
    <h2 className="text-xl text-teal-600">Next Theme With Tailwindcss v4
    </h2>
</div>
Enter fullscreen mode Exit fullscreen mode

When the dark theme is selected, the style applied for dark mode will be applied. In the example, bg-amber-500 will be applied for the dark theme.

Now add colors variables as you prefer.

@theme {
  /* theme background color */
  /* Light mode default */
  --bg-color-light-default: hsl(220, 14%, 96%);
 /* Dark mode default */
  --bg-color-dark-default: hsl(207, 95%, 8%);  
}
Enter fullscreen mode Exit fullscreen mode

Use the theme, directive as you use in tailwindcss.

Add :root selector for each theme. The application will maintain background colors from the root of your application.

:root[data-theme="light"] {
  background-color: var(--color-background-light);
}

:root[data-theme="dark"] {
  background-color: var(--color-background-dark);
}

Enter fullscreen mode Exit fullscreen mode

Step 04: Add theme toggler button

To change different themes, I am going to use lucid react icons library.
As it is a user's interactive button, it will be a client component. I make it different component for it, named ThemetogglerBtn.tsx.

pnpm install lucide-react
Enter fullscreen mode Exit fullscreen mode

ThemetogglerBtn.tsx

'use client';

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Sun, Moon } from 'lucide-react';

export default function ThemeSwitcher() {
  const [mounted, setMounted] = useState(false);
  const { theme, setTheme, resolvedTheme } = useTheme();

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

const toggleTheme = () => {
  setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
}

  if (!mounted) {
    return (
      <div className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700">
      </div>
    )
  }

  const currentIcon = resolvedTheme === 'dark' ? (<Sun size={24} className="text-yellow-500" />) :
  (<Moon size={24} strokeWidth={2} className="text-gray-700"/>);

  return (
    <button
      onClick={toggleTheme}
      className="p-2 rounded-full flex items-center           justify-center transition-colors bg-[--bg-light] hover:bg-[--bg-light] dark:hover:bg-[--bg-light]"
      aria-label={resolvedTheme === 'dark' ? "Switch to light theme" : "Switch to dark theme"}
      title={resolvedTheme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
    >
      {currentIcon}
      <span className="sr-only">Theme switcher button</span>
    </button>
  )
};
Enter fullscreen mode Exit fullscreen mode

👉 useTheme hook

import use-theme() from next-themes
const {theme, setTheme, resolvedTheme = useTheme()

🌗 theme shows the current selected theme.
🌗 setTheme, with the setter function, we can set a new theme.
🌗 resolvedTheme: It acknowledges the system presence theme. Currently, is it darkor light theme active on the user's device?

👉 mounted state

In next.js useTheme may mismatch hydration for client and server side components. To avoid this, we use mounted state.

const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true)
},[])
Enter fullscreen mode Exit fullscreen mode
  • Initially mounted = false
  • After rendering client component, it becomes true
  • So it ensures theme toggler works only in client side.
🌗 toggleTheme theme toggler function
const toggleTheme =() => {
setTheme(resolvedTheme = 'dark' ? 'light' : 'dark');
}
Enter fullscreen mode Exit fullscreen mode
  • If the current value of resolvedTheme === dark, then it will change the theme to light. And if resolvedTheme === light, it will change to dark.
🌗 Change icon
const currentIcon = resolvedTheme === `dark` ?
(<Sun size={24} className='text-yellow-500' />) 
: <Moon size={24} className='text-gray-700'/>)
Enter fullscreen mode Exit fullscreen mode
  • If dark mode is enabled, it will render Sun icon. And for light mode Moon icon.
🌗 UI in button
<button onClick={toggleTheme}>
{currentIcon}
 <span className="sr-only">Theme switcher button</span>
</button>
Enter fullscreen mode Exit fullscreen mode
  • Clicking on the button activates toggleTheme function.
  • {currentIcon}: renders icon.
  • <span className="sr-only">Theme switcher button</span>: sr-only is a good practice for accessibility.

✅ At a glance

  1. Install next-themes
  2. Wrap app with <ThemeProvider>
  3. Configure global.css with @custom-variant dark
  4. Add theme toggler button with useTheme hook
  5. Enjoy smooth dark/light theme switching 🎉

Top comments (0)