ProgrUmar Logo
Module 3: Styling & UI

Dark Mode with Tailwind & next-themes

Duration: 13 mins

Flicker-free Dark Mode

Use next-themes with the attribute="class" strategy so Tailwind's dark: utilities work. Wrap your root layout in a ThemeProvider to expose the toggle across the app.

Dark Mode with Tailwind & next-themes

Dark mode is no longer a nice-to-have — users expect it. Get it wrong and you get the dreaded white flash on page load, or worse, a dark mode that ignores the user's system preference. Get it right and it feels seamless — the app remembers the user's choice, respects their OS setting by default, and switches instantly without any flicker.

This lesson builds a complete, production-ready dark mode system from scratch — system preference detection, user-controlled toggle, localStorage persistence, and zero flash on page load. We'll use next-themes with Tailwind's dark: variant.


1. The Flash Problem — Why Dark Mode Is Hard

Here's the core challenge. Next.js renders HTML on the server. The server doesn't know the user's preferred theme — that's stored in localStorage in the browser. So the server renders with the default theme (light), sends the HTML, and then JavaScript runs in the browser, reads localStorage, and switches to dark mode. That switch causes a white flash — sometimes called FOUC (Flash of Unstyled Content).

The solution is to inject a tiny blocking script into the HTML <head> that reads localStorage before the page renders, and adds the correct theme class to the <html> element before any content paints. next-themes handles this for you automatically.


2. Setting Up next-themes

Install the package:

npm install next-themes

Configure Tailwind to use the class-based dark mode strategy — this means dark mode activates when a .dark class is on the <html> element, rather than relying purely on the OS media query:

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

/* Tell Tailwind to use class-based dark mode */
@variant dark (&.dark);

Now create a ThemeProvider wrapper. It must be a Client Component because it uses React context internally:

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

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

export function ThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <NextThemesProvider
      attribute="class"        // Add "dark" class to <html> element
      defaultTheme="system"    // Default to system preference
      enableSystem             // Detect OS dark/light preference
      disableTransitionOnChange // Prevent flash during theme switch
    >
      {children}
    </NextThemesProvider>
  );
}

Wrap your root layout with the provider. Keep the root layout itself a Server Component — only the provider needs to be a Client Component:

// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";

const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });

export const metadata: Metadata = {
  title: "My App",
  description: "My Next.js application",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      {/*
        suppressHydrationWarning on <html> is required —
        next-themes modifies the class attribute before hydration,
        which would normally trigger a React hydration mismatch warning.
      */}
      <body className={inter.variable}>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Important: suppressHydrationWarning on the <html> element is not optional — without it you'll get React hydration warnings in development because next-themes modifies the class attribute before React hydrates.


3. Adding dark: Classes to Components

With the setup complete, prefix any Tailwind utility with dark: to apply it when dark mode is active:

// Basic dark mode styles
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
  <h1 className="text-2xl font-bold">Hello</h1>
</div>

// Cards
<div className="
  bg-white dark:bg-gray-800
  border border-gray-200 dark:border-gray-700
  rounded-lg p-6 shadow-sm dark:shadow-gray-900/20
">
  <h2 className="text-gray-900 dark:text-gray-100 font-semibold">Card Title</h2>
  <p className="text-gray-600 dark:text-gray-400 mt-2">Card description.</p>
</div>

// Navigation
<nav className="bg-white dark:bg-gray-950 border-b border-gray-200 dark:border-gray-800">
  <a className="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
    Home
  </a>
</nav>

// Inputs
<input className="
  bg-white dark:bg-gray-800
  border border-gray-300 dark:border-gray-600
  text-gray-900 dark:text-gray-100
  placeholder:text-gray-400 dark:placeholder:text-gray-500
  rounded-lg px-3 py-2
  focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
" />

4. Building a Theme Toggle Component

The useTheme hook from next-themes gives you the current theme and a function to change it. Build a toggle button users can click:

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

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

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

  // Only render after mounting to avoid hydration mismatch
  // (server doesn't know the theme, client does)
  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    // Render a placeholder with the same dimensions to prevent layout shift
    return <div className="w-9 h-9 rounded-lg" />;
  }

  const isDark = resolvedTheme === "dark";

  return (
    <button
      onClick={() => setTheme(isDark ? "light" : "dark")}
      className="
        w-9 h-9 rounded-lg flex items-center justify-center
        bg-gray-100 dark:bg-gray-800
        hover:bg-gray-200 dark:hover:bg-gray-700
        text-gray-600 dark:text-gray-400
        transition-colors duration-200
      "
      aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
    >
      {isDark ? (
        // Sun icon — shown in dark mode
        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <circle cx="12" cy="12" r="4"/>
          <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
        </svg>
      ) : (
        // Moon icon — shown in light mode
        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
        </svg>
      )}
    </button>
  );
}

The mounted check is essential. On the server and during the initial client render, the theme is unknown. If you render the toggle without this check, you'll get a hydration mismatch because the server renders one icon and the client might render another. The placeholder div keeps the layout stable.

Three-way Toggle — Light / Dark / System

Power users want a three-way toggle that includes the "follow system" option:

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

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

const themes = [
  { value: "light",  label: "Light",  icon: "☀️" },
  { value: "dark",   label: "Dark",   icon: "🌙" },
  { value: "system", label: "System", icon: "💻" },
] as const;

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

  useEffect(() => setMounted(true), []);
  if (!mounted) return null;

  return (
    <div className="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
      {themes.map((t) => (
        <button
          key={t.value}
          onClick={() => setTheme(t.value)}
          className={`
            px-3 py-1.5 rounded-md text-sm font-medium transition-colors
            ${theme === t.value
              ? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
              : "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
            }
          `}
          aria-pressed={theme === t.value}
        >
          {t.icon} {t.label}
        </button>
      ))}
    </div>
  );
}

5. Using CSS Variables for Theming

For complex apps, managing dark: variants on every element gets repetitive. A cleaner approach is defining semantic CSS variables that change value based on the theme — then you only reference the variable, not the dark/light colour directly:

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

@variant dark (&.dark);

/* Light mode variables (default) */
:root {
  --bg-primary:   255 255 255;     /* white */
  --bg-secondary: 249 250 251;     /* gray-50 */
  --bg-elevated:  255 255 255;     /* white */

  --text-primary:   17 24 39;      /* gray-900 */
  --text-secondary: 107 114 128;   /* gray-500 */
  --text-muted:     156 163 175;   /* gray-400 */

  --border-color: 229 231 235;     /* gray-200 */
  --border-subtle: 243 244 246;    /* gray-100 */

  --ring-color: 59 130 246;        /* blue-500 */
}

/* Dark mode variables */
.dark {
  --bg-primary:   3 7 18;          /* gray-950 */
  --bg-secondary: 17 24 39;        /* gray-900 */
  --bg-elevated:  31 41 55;        /* gray-800 */

  --text-primary:   249 250 251;   /* gray-50 */
  --text-secondary: 156 163 175;   /* gray-400 */
  --text-muted:     107 114 128;   /* gray-500 */

  --border-color: 55 65 81;        /* gray-700 */
  --border-subtle: 31 41 55;       /* gray-800 */

  --ring-color: 96 165 250;        /* blue-400 */
}

Note the values are stored as RGB channels without the rgb() wrapper — this lets Tailwind's opacity modifier (bg-primary/50) work correctly with them:

/* app/globals.css — continued */
@theme {
  --color-bg-primary:   rgb(var(--bg-primary));
  --color-bg-secondary: rgb(var(--bg-secondary));
  --color-bg-elevated:  rgb(var(--bg-elevated));

  --color-text-primary:   rgb(var(--text-primary));
  --color-text-secondary: rgb(var(--text-secondary));
  --color-text-muted:     rgb(var(--text-muted));

  --color-border:        rgb(var(--border-color));
  --color-border-subtle: rgb(var(--border-subtle));
}
// Now components are much cleaner — no dark: prefix needed for base styles
<div className="bg-bg-primary text-text-primary border border-border rounded-lg p-6">
  <h2 className="text-text-primary font-semibold">Card Title</h2>
  <p className="text-text-secondary mt-2">Card description.</p>
</div>

// Only use dark: for things that genuinely differ beyond the semantic tokens
<button className="bg-blue-600 dark:bg-blue-500 text-white">
  Primary Action
</button>

6. Dark Mode for Images

Some images need different versions for light and dark mode — logos, illustrations, and diagrams are common examples. Use the dark: variant with hidden and block to swap them:

// Light and dark logo variants
<div>
  <img
    src="/logo-light.svg"
    alt="My App"
    className="dark:hidden"          // Visible in light mode, hidden in dark
  />
  <img
    src="/logo-dark.svg"
    alt="My App"
    className="hidden dark:block"    // Hidden in light mode, visible in dark
  />
</div>

For illustrations or screenshots that just need to look softer in dark mode without swapping entirely, reduce opacity instead:

<img
  src="/screenshot.png"
  alt="App screenshot"
  className="opacity-100 dark:opacity-80 dark:brightness-90"
/>

7. Smooth Theme Transitions

By default, switching themes is instant — elements jump from light to dark colours. You can add a smooth transition, but be careful: transitioning every property on every element can cause visual glitches. Target only the properties that change:

/* app/globals.css */

/* Add transition only to properties that change between themes */
body,
body * {
  transition:
    background-color 0.2s ease,
    border-color 0.2s ease,
    color 0.15s ease;
}

The disableTransitionOnChange prop on NextThemesProvider temporarily disables transitions during the initial theme detection — preventing a flash of transition animation on page load. Keep it set to true even if you add custom transitions.


8. Reading the Theme in Server Components

useTheme is a client-side hook — you can't use it in Server Components. For server-rendered theme-aware content (rare, but sometimes needed), you can read the theme from cookies. next-themes stores it in a cookie when server-side rendering is configured:

// components/ThemeProvider.tsx — enable cookie storage
<NextThemesProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  disableTransitionOnChange
  storageKey="theme"        // localStorage key
>
// app/some-server-component.tsx
import { cookies } from "next/headers";

export default async function ServerThemeAwareComponent() {
  const cookieStore = await cookies();
  const theme = cookieStore.get("theme")?.value ?? "system";

  return (
    <div>
      <p>Current theme preference: {theme}</p>
    </div>
  );
}

This is rarely needed — the CSS-based dark mode system handles visual theming automatically. Only reach for server-side theme reading if you need to conditionally render different content (not just different styles) based on the theme.


9. A Complete Dark Mode Component Checklist

When building or reviewing a component for dark mode support, check these:

Element Light Dark
Page background bg-white dark:bg-gray-950
Card/panel background bg-white dark:bg-gray-800
Body text text-gray-900 dark:text-gray-100
Secondary text text-gray-500 dark:text-gray-400
Borders border-gray-200 dark:border-gray-700
Input background bg-white dark:bg-gray-900
Input border border-gray-300 dark:border-gray-600
Placeholder text placeholder:text-gray-400 dark:placeholder:text-gray-500
Hover background hover:bg-gray-100 dark:hover:bg-gray-800
Code blocks bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200
Shadows shadow-sm dark:shadow-gray-900/50

10. Common Gotchas

  • Forgetting suppressHydrationWarning on <html>. Without it, React throws a hydration warning in development because next-themes adds a class to <html> before React hydrates. This is a false positive — the app works fine — but the warning is noisy and can mask real issues.
  • Rendering theme-dependent UI without the mounted check. Any component that renders differently based on the theme (like the toggle button) must check mounted before rendering. The server doesn't know the theme, so the first render must match the server output — render a neutral placeholder instead.
  • Using theme instead of resolvedTheme for icons. theme can be "system" — a string that doesn't tell you whether the system is in dark or light mode. Use resolvedTheme which is always either "light" or "dark".
  • Forgetting @variant dark (&.dark) in globals.css. Without this, Tailwind's dark: variant uses the default media query strategy instead of the class strategy — your toggle won't work.
  • Missing dark styles on third-party components. If you use a library that applies its own colours (charts, maps, rich text editors), check if it has a dark mode prop or theme option. The CSS :global escape hatch in CSS Modules or Tailwind's dark:[&_.third-party-class] syntax can override third-party styles when needed.

Key Takeaways

  • Use next-themes with attribute="class" and @variant dark (&.dark) in Tailwind for class-based dark mode.
  • Add suppressHydrationWarning to the <html> tag — it's required, not optional.
  • Always check mounted before rendering theme-dependent UI to avoid hydration mismatches.
  • Use resolvedTheme not theme when you need to know the actual current theme — theme can be "system".
  • CSS variables that change value with the .dark class keep components clean — fewer dark: prefixes needed.
  • Store colours as raw RGB channel values (without rgb()) to enable Tailwind opacity modifiers.
  • disableTransitionOnChange on the provider prevents a flash of transition animation on initial page load.

That wraps up Module 3 — Styling & UI! You now have a complete styling toolkit: CSS Modules for scoped component styles, Tailwind for utility-first rapid development, next/font for zero-layout-shift typography, and a flicker-free dark mode system. In Module 4 we move to SEO — the Metadata API, Open Graph images, sitemaps, structured data, and Core Web Vitals.

Chat with us