
Dark Mode Done Right: From CSS Variables to SSR Flicker Fix
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-bgover--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
localStorageorwindow.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:
suppressHydrationWarningon<html>— the server renders withoutdata-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
localStorageis blocked (private browsing, strict browser extensions). - No
asyncordefer— 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"— usesdata-themeattribute instead of a.darkclassdefaultTheme="system"— falls back to OS preference if no stored preference existsenableSystem— enables the'system'option (follows OS preference)disableTransitionOnChange— prevents CSS transitions from firing during theme switch, avoiding flash animations on every elementstorageKey="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-schemeas the default fallback - ✅ Store user intent in
localStorage('system'|'light'|'dark') - ✅ Blocking inline script in
<head>to eliminate the flash of wrong theme - ✅
suppressHydrationWarningon 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.
Get weekly highlights
No spam, unsubscribe anytime.
Railway
Deploy fullstack apps effortlessly. Postgres, Redis, Node in just a few clicks.
Galaxy.ai
AI workspace for developers — all AI tools in one place. Supercharge your workflow.



Comments (0)
Sign in to comment
No comments yet. Be the first to comment!