Skip to main content
Back to Blog
Tutorials
3 min read
November 6, 2024

How to Add Dark Mode to Your Next.js Website with Tailwind CSS

A step-by-step guide to implementing dark mode in a Next.js application using Tailwind CSS and next-themes. Covers system preference detection, toggle buttons, and persistent preferences.

Ryel Banfield

Founder & Lead Developer

Dark mode is no longer optional. Users expect it, and it improves accessibility for users sensitive to bright screens. Here is how to implement it properly in a Next.js application with Tailwind CSS.

Prerequisites

  • Next.js 14+ (App Router)
  • Tailwind CSS v4 or v3
  • Basic React and TypeScript knowledge

Step 1: Install next-themes

pnpm add next-themes

next-themes handles theme persistence, system preference detection, and flash-of-unstyled-content prevention.

Step 2: Configure Tailwind CSS

In your Tailwind CSS configuration, enable dark mode via class strategy:

Tailwind v4 (CSS-based config):

/* globals.css */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

Tailwind v3 (JS config):

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
}

The class strategy applies dark mode when a .dark class is present on the <html> element, rather than relying on the prefers-color-scheme media query.

Step 3: Create a Theme Provider

// components/providers/ThemeProvider.tsx
"use client";

import { ThemeProvider as NextThemesProvider } from "next-themes";

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

Key props:

  • attribute="class": Adds .dark class to <html>
  • defaultTheme="system": Respects OS preference by default
  • enableSystem: Allows system preference detection
  • disableTransitionOnChange: Prevents flash during theme switch

Step 4: Wrap Your Layout

// app/layout.tsx
import { ThemeProvider } from "@/components/providers/ThemeProvider";

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

suppressHydrationWarning is needed because next-themes modifies the <html> element on the client, which would otherwise trigger a hydration mismatch warning.

Step 5: Create a Theme Toggle

// components/ModeToggle.tsx
"use client";

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

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

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

  if (!mounted) {
    return <button aria-label="Toggle theme" className="h-9 w-9" />;
  }

  return (
    <button
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
      aria-label="Toggle theme"
      className="rounded-md p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
    >
      {theme === "dark" ? (
        <SunIcon className="h-5 w-5" />
      ) : (
        <MoonIcon className="h-5 w-5" />
      )}
    </button>
  );
}

The mounted check prevents hydration mismatches. The component renders a placeholder during SSR and the actual toggle after hydration.

Step 6: Use Dark Mode Classes

Now use Tailwind's dark: variant throughout your components:

<div className="bg-white dark:bg-gray-900">
  <h1 className="text-gray-900 dark:text-white">
    Hello World
  </h1>
  <p className="text-gray-600 dark:text-gray-400">
    This text adapts to the current theme.
  </p>
</div>

Step 7: Use CSS Custom Properties for Semantic Colors

For larger projects, define semantic color tokens:

/* globals.css */
:root {
  --background: #ffffff;
  --foreground: #0a0a0a;
  --muted: #f5f5f5;
  --muted-foreground: #737373;
  --border: #e5e5e5;
}

.dark {
  --background: #0a0a0a;
  --foreground: #fafafa;
  --muted: #262626;
  --muted-foreground: #a3a3a3;
  --border: #262626;
}

Reference these in Tailwind:

<div className="bg-[var(--background)] text-[var(--foreground)]">
  Content
</div>

Or better, extend your Tailwind theme to use these variables.

Common Pitfalls

Flash of Unstyled Content (FOUC)

If you see a flash of the wrong theme on page load, ensure:

  1. disableTransitionOnChange is set on ThemeProvider
  2. The theme script runs before the page renders (next-themes handles this)
  3. You are using suppressHydrationWarning on <html>

Hydration Mismatches

Theme-dependent content must be guarded with the mounted check pattern shown above. During SSR, the server does not know the user's theme preference.

Images and SVGs

Provide dark mode variants for logos and illustrations:

<img
  src="/logo-light.svg"
  className="block dark:hidden"
  alt="Logo"
/>
<img
  src="/logo-dark.svg"
  className="hidden dark:block"
  alt="Logo"
/>

Third-Party Components

Some third-party components do not respect dark mode classes. Use CSS custom properties to force their colors, or wrap them in a container with explicit background and text colors.

Testing

  1. Toggle between light/dark/system modes
  2. Verify persistence across page reloads
  3. Test with OS dark mode setting
  4. Check for FOUC on initial load
  5. Verify all text has sufficient contrast in both modes

Need Help?

Dark mode is one of many UX improvements we implement for our clients. Contact us to discuss your website's design needs.

Next.jsTailwind CSSdark modetutorialReact

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles