All insights
Engineering6 min read

Dark Mode in Next.js with Tailwind CSS: The Complete Implementation

Dark mode done right means no flash of wrong theme, correct system preference detection, and persistent user preference — all without layout shift. Here's how to build it.

NC

Nextcraft Engineering Team

The Three Requirements

Good dark mode implementation needs to satisfy three requirements simultaneously:

  1. Respect system preference (prefers-color-scheme: dark) on first visit
  2. Persist user choice when they manually switch (stored in localStorage or a cookie)
  3. No flash of wrong theme — the theme must be applied before the first paint

These requirements conflict. System preference and user preference are client-side concepts. But no flash requires the theme to be applied server-side or via a synchronous script before React hydrates. Getting all three right requires careful architecture.

The Approach: Class-Based Theming

Tailwind's dark mode with class strategy (as opposed to media) gives you explicit control:

code
// tailwind.config.ts
export default {
  darkMode: 'class', // Enable class-based dark mode
  // ...
};

With this setting, adding the dark class to <html> activates all dark: variants. Your theme switch is a single DOM operation.

Preventing Flash with a Blocking Script

The flash happens because Next.js renders HTML on the server (which doesn't know the user's preference) and sends it to the browser, which then runs JavaScript that applies the correct theme — causing a visible repaint.

The fix: a synchronous, inline <script> in <head> that runs before any rendering:

code
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              try {
                const stored = localStorage.getItem('theme');
                const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
                const theme = stored ?? (prefersDark ? 'dark' : 'light');
                if (theme === 'dark') document.documentElement.classList.add('dark');
              } catch (e) {}
            `,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

The suppressHydrationWarning on <html> is necessary because the class will differ between server render (no dark class) and client (potentially with dark class). Without it, React logs a hydration mismatch warning.

The try/catch wraps the localStorage access — it throws in private browsing on some browsers.

The Theme Context

code
// lib/theme-context.tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

const ThemeContext = createContext<{
  theme: Theme;
  setTheme: (theme: Theme) => void;
}>({ theme: 'system', setTheme: () => {} });

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>('system');

  useEffect(() => {
    const stored = localStorage.getItem('theme') as Theme | null;
    if (stored) setThemeState(stored);
  }, []);

  function setTheme(newTheme: Theme) {
    setThemeState(newTheme);
    
    if (newTheme === 'system') {
      localStorage.removeItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      document.documentElement.classList.toggle('dark', prefersDark);
    } else {
      localStorage.setItem('theme', newTheme);
      document.documentElement.classList.toggle('dark', newTheme === 'dark');
    }
  }

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => useContext(ThemeContext);
code
// app/layout.tsx — wrap children in provider
import { ThemeProvider } from '@/lib/theme-context';

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

The Toggle Component

code
// components/ThemeToggle.tsx
'use client';

import { useTheme } from '@/lib/theme-context';
import { Sun, Moon, Monitor } from 'lucide-react';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <div className="flex gap-1 rounded-lg bg-surface-elevated p-1">
      {[
        { value: 'light', icon: Sun },
        { value: 'system', icon: Monitor },
        { value: 'dark', icon: Moon },
      ].map(({ value, icon: Icon }) => (
        <button
          key={value}
          onClick={() => setTheme(value as 'light' | 'system' | 'dark')}
          className={`rounded-md p-1.5 transition-colors ${
            theme === value
              ? 'bg-white text-gray-900 shadow dark:bg-gray-800 dark:text-gray-100'
              : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
          }`}
          aria-label={`Switch to ${value} mode`}
        >
          <Icon className="h-4 w-4" />
        </button>
      ))}
    </div>
  );
}

Using Dark Mode in Components

With Tailwind's dark: variants:

code
export function Card({ title, description }: { title: string; description: string }) {
  return (
    <div className="rounded-xl bg-white p-6 shadow dark:bg-gray-800">
      <h3 className="text-gray-900 dark:text-gray-100">{title}</h3>
      <p className="text-gray-600 dark:text-gray-400">{description}</p>
    </div>
  );
}

If you've set up CSS custom properties as your token layer (as described in our design tokens guide), the dark mode application is even simpler — the dark class on <html> swaps all your token values automatically, with no dark: prefixes needed in component classes.

System Preference Changes

Handle the edge case where a user's system preference changes while they have the "system" theme selected:

code
useEffect(() => {
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  
  const handleChange = (e: MediaQueryListEvent) => {
    if (theme === 'system') {
      document.documentElement.classList.toggle('dark', e.matches);
    }
  };

  mediaQuery.addEventListener('change', handleChange);
  return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);

With this, the UI responds in real-time when a user switches their OS theme — matching the behavior users expect from well-built native apps.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.