Bỏ qua để vào nội dung chính
Dark Mode Done Right: From CSS Variables to SSR Flicker Fix

Dark Mode Done Right: From CSS Variables to SSR Flicker Fix

Bởi Charlotte Adams
03 thg 4, 20269 phút đọc

A deep dive into implementing dark mode correctly in Next.js and Tailwind CSS — covering CSS custom properties architecture, system preference detection, localStorage persistence, and fixing the SSR hydration flicker with a blocking script and next-themes.

Dark mode is no longer a "nice to have." Users expect it, designers demand it, and operating systems now ship with it enabled by default. But implementing dark mode correctly — without flicker, without hydration mismatches, and with proper multi-theme support — is trickier than it looks.

This deep dive covers everything: architecting your CSS custom properties, detecting system preferences, persisting user choices, and fixing the notorious SSR flash-of-wrong-theme in Next.js. Let's build this the right way.

1. The Foundation: CSS Custom Properties Architecture

The cornerstone of any robust theming system is a well-structured set of CSS custom properties (variables). Rather than hardcoding color values, you define semantic tokens that adapt based on the active theme.

/* styles/globals.css */
:root {
  --color-bg: #ffffff;
  --color-bg-secondary: #f4f4f5;
  --color-text: #09090b;
  --color-text-muted: #71717a;
  --color-border: #e4e4e7;
  --color-accent: #6366f1;
  --color-accent-hover: #4f46e5;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
}

[data-theme="dark"] {
  --color-bg: #09090b;
  --color-bg-secondary: #18181b;
  --color-text: #fafafa;
  --color-text-muted: #a1a1aa;
  --color-border: #27272a;
  --color-accent: #818cf8;
  --color-accent-hover: #6366f1;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
}

Notice a few key decisions here:

  • Semantic naming--color-bg over --color-white. Semantic names describe purpose, not value, making them theme-agnostic.
  • Data attribute selector[data-theme="dark"] on the <html> element gives you fine-grained control and extensibility for future themes.
  • Shadow adjustments — shadows need to be darker on dark backgrounds. Don't forget these subtle details.

You can then use these variables anywhere in your CSS:

.card {
  background: var(--color-bg-secondary);
  border: 1px solid var(--color-border);
  box-shadow: var(--shadow-sm);
  color: var(--color-text);
}

2. System Preference Detection with prefers-color-scheme

Modern browsers expose the user's OS-level color preference via the prefers-color-scheme media query. This should always be the default — respect the user's system setting before any explicit choice is made.

/* Automatic system preference support */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #09090b;
    --color-bg-secondary: #18181b;
    --color-text: #fafafa;
    /* ... rest of dark tokens */
  }
}

On the JavaScript side, you can detect and react to preference changes programmatically:

const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');

// Get current value
console.log(prefersDark.matches); // true or false

// Listen for real-time OS theme changes
prefersDark.addEventListener('change', (e) => {
  if (getStoredTheme() === 'system') {
    applyTheme(e.matches ? 'dark' : 'light');
  }
});

This is important: users can switch their OS theme while your app is open. Your theme system should react dynamically — but only if the user hasn't explicitly overridden their preference in your app.

3. localStorage Persistence: Remembering User Choice

When a user explicitly clicks your theme toggle, you need to remember that choice across page loads. localStorage is the right tool here.

// lib/theme.ts
type Theme = 'light' | 'dark' | 'system';

export function getStoredTheme(): Theme {
  if (typeof window === 'undefined') return 'system';
  return (localStorage.getItem('theme') as Theme) ?? 'system';
}

export function setStoredTheme(theme: Theme): void {
  localStorage.setItem('theme', theme);
}

export function resolveTheme(theme: Theme): 'light' | 'dark' {
  if (theme === 'system') {
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  }
  return theme;
}

export function applyTheme(theme: Theme): void {
  const resolved = resolveTheme(theme);
  document.documentElement.setAttribute('data-theme', resolved);
  setStoredTheme(theme);
}

The key insight: store the user's intent ('system', 'light', or 'dark'), not the resolved value. This way, if a user set their preference to 'system' and later switches their OS theme, your app follows along correctly.

4. The SSR Problem: Flash of Wrong Theme

Here's where most implementations fall apart. When using Next.js (or any SSR framework), there's a fundamental timing problem:

  • The server renders HTML without knowing the user's theme preference (it can't access localStorage or window.matchMedia)
  • The browser receives and paints the server HTML — usually in light mode
  • React hydrates and JavaScript runs, reading the stored theme
  • The theme switches — causing a visible, jarring flash

This flash happens in the ~100–300ms window between the initial paint and JavaScript execution. It's especially bad for dark mode users who get a blinding white flash on every page load.

The Blocking Script Fix

The solution is to inject a blocking script in the <head> — before any rendering occurs. This script runs synchronously, before the browser paints anything, and sets the correct theme immediately.

// app/layout.tsx (Next.js 13+ App Router)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  var stored = localStorage.getItem('theme');
                  var prefersDark = window.matchMedia(
                    '(prefers-color-scheme: dark)'
                  ).matches;
                  var theme = stored === 'dark' || stored === 'light'
                    ? stored
                    : prefersDark ? 'dark' : 'light';
                  document.documentElement.setAttribute('data-theme', theme);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

A few critical details about this approach:

  • suppressHydrationWarning on <html> — the server renders without data-theme, but the blocking script adds it client-side before React hydrates. Without this prop, React will warn about the attribute mismatch.
  • Wrapped in an IIFE with try/catch — protects against environments where localStorage is blocked (private browsing, strict browser extensions).
  • No async or defer — this is intentional. The script must run synchronously before the first paint to prevent the flash.
  • Minimal code — keep this script as small as possible since it blocks rendering. Every millisecond counts.

5. Tailwind CSS Dark Mode: Class Strategy

Tailwind's dark mode support has two modes: media and class. For any serious application, you want class mode — it gives you manual control over when dark styles activate, which is required for user-toggled themes.

// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  darkMode: ['class', '[data-theme="dark"]'],
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      colors: {
        background: 'var(--color-bg)',
        'background-secondary': 'var(--color-bg-secondary)',
        foreground: 'var(--color-text)',
        'foreground-muted': 'var(--color-text-muted)',
        border: 'var(--color-border)',
        accent: 'var(--color-accent)',
      },
    },
  },
};

export default config;

The darkMode: ['class', '[data-theme="dark"]'] syntax (Tailwind v3.4+) tells Tailwind to activate dark variants when the [data-theme="dark"] selector is present on an ancestor. This pairs perfectly with our data attribute approach.

Now use Tailwind's dark utilities seamlessly:

<div className="bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100">
  <p className="text-zinc-600 dark:text-zinc-400">
    Adaptive content
  </p>
</div>

Or better yet, use your semantic CSS variable tokens through the extended Tailwind config:

<div className="bg-background text-foreground border border-border">
  <p className="text-foreground-muted">
    Cleaner, more maintainable, and theme-agnostic
  </p>
</div>

6. next-themes: The Production-Ready Solution

If you're building a production Next.js app, next-themes solves all of the above problems elegantly — blocking script injection, system preference detection, localStorage persistence, and React context — all in one package.

npm install next-themes
// app/providers.tsx
'use client';

import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="data-theme"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </ThemeProvider>
  );
}
// app/layout.tsx
import { Providers } from './providers';

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

Then, consuming the theme in any client component:

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

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

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

  // Prevent hydration mismatch on the toggle itself
  useEffect(() => setMounted(true), []);
  if (!mounted) return <div className="w-9 h-9" />;

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-lg bg-background-secondary hover:bg-border transition-colors"
      aria-label="Toggle theme"
    >
      {theme === 'dark' ? (
        <Sun className="w-5 h-5" />
      ) : (
        <Moon className="w-5 h-5" />
      )}
    </button>
  );
}

Note the mounted guard — this is critical. Since the server doesn't know the theme, theme is undefined during SSR. Rendering theme-dependent UI (like which icon to show) before mounting causes hydration mismatches. The placeholder <div> ensures server and client render the same DOM initially.

Key next-themes Options

  • attribute="data-theme" — uses data-theme attribute instead of a .dark class
  • defaultTheme="system" — falls back to OS preference if no stored preference exists
  • enableSystem — enables the 'system' option (follows OS preference)
  • disableTransitionOnChange — prevents CSS transitions from firing during theme switch, avoiding flash animations on every element
  • storageKey="theme" — customize the localStorage key

7. Going Beyond Dark/Light: Multi-Theme Support

Once your architecture is in place, extending to multiple themes is straightforward. Think beyond binary dark/light — you can support brand themes, high-contrast modes, or seasonal color palettes.

/* styles/globals.css */
:root, [data-theme="light"] {
  --color-bg: #ffffff;
  --color-accent: #6366f1;
}

[data-theme="dark"] {
  --color-bg: #09090b;
  --color-accent: #818cf8;
}

[data-theme="rose"] {
  --color-bg: #fff1f2;
  --color-bg-secondary: #ffe4e6;
  --color-text: #1c1917;
  --color-accent: #f43f5e;
  --color-border: #fecdd3;
}

[data-theme="forest"] {
  --color-bg: #052e16;
  --color-bg-secondary: #14532d;
  --color-text: #f0fdf4;
  --color-accent: #4ade80;
  --color-border: #166534;
}
// app/providers.tsx — multi-theme config
<ThemeProvider
  attribute="data-theme"
  defaultTheme="system"
  enableSystem
  themes={['light', 'dark', 'rose', 'forest']}
  disableTransitionOnChange
>
  {children}
</ThemeProvider>
// components/ThemeSwitcher.tsx
'use client';

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

const themes = [
  { id: 'system', label: 'System', emoji: '💻' },
  { id: 'light', label: 'Light', emoji: '☀️' },
  { id: 'dark', label: 'Dark', emoji: '🌙' },
  { id: 'rose', label: 'Rose', emoji: '🌸' },
  { id: 'forest', label: 'Forest', emoji: '🌲' },
];

export function ThemeSwitcher() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;

  return (
    <div className="flex gap-2 flex-wrap">
      {themes.map((t) => (
        <button
          key={t.id}
          onClick={() => setTheme(t.id)}
          className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors
            ${theme === t.id
              ? 'bg-accent text-white'
              : 'bg-background-secondary text-foreground-muted hover:bg-border'
            }`}
        >
          {t.emoji} {t.label}
        </button>
      ))}
    </div>
  );
}

High-Contrast and Accessibility Themes

For truly inclusive applications, support an explicit high-contrast theme that respects the prefers-contrast: more media query:

[data-theme="high-contrast"] {
  --color-bg: #000000;
  --color-text: #ffffff;
  --color-border: #ffffff;
  --color-accent: #ffff00;
}

/* Auto-apply when OS requests high contrast */
@media (prefers-contrast: more) {
  :root:not([data-theme]) {
    --color-bg: #000000;
    --color-text: #ffffff;
    --color-border: #ffffff;
    --color-accent: #ffff00;
  }
}

8. Smooth Theme Transitions Without the Jank

A common desire is to animate the theme switch. The trick: you want transitions on user-triggered changes, but absolutely not on the initial page load (that would cause its own kind of flash).

next-themes handles this with disableTransitionOnChange, which temporarily disables all transitions during the switch. If you're rolling your own solution:

function setThemeWithTransition(theme: string) {
  // Disable transitions momentarily
  document.documentElement.classList.add('no-transition');
  document.documentElement.setAttribute('data-theme', theme);

  // Re-enable after the browser has applied the change
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      document.documentElement.classList.remove('no-transition');
    });
  });
}
/* CSS */
.no-transition * {
  transition: none !important;
}

The double requestAnimationFrame ensures the theme change is painted before transitions are re-enabled — a technique borrowed from browser rendering internals.

The Production Architecture Checklist

Here's a consolidated summary for building a production-grade dark mode system:

  • Semantic CSS custom properties with [data-theme] attribute on <html>
  • System preference detection via prefers-color-scheme as the default fallback
  • Store user intent in localStorage ('system' | 'light' | 'dark')
  • Blocking inline script in <head> to eliminate the flash of wrong theme
  • suppressHydrationWarning on the <html> element
  • Tailwind darkMode: ['class', '[data-theme="dark"]'] for utility-first dark styles
  • Mounted guard on any theme-dependent client component UI
  • next-themes for production Next.js apps — it handles everything above
  • Multi-theme support for brand, accessibility, and preference themes

Dark mode isn't just an aesthetic feature — it's a statement about respecting your users' preferences and their eyes. Done right, it should be invisible: users simply see their preferred experience, without flash, without jank, and without fighting your app's defaults.

The combination of CSS custom properties for theming architecture, a blocking inline script for SSR safety, and next-themes for React integration covers 99% of real-world production use cases. Start with this foundation and you'll have a theming system that's easy to extend, accessible by default, and seamlessly integrated into your Next.js + Tailwind stack.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan