Tailwind Dark/Light Toggle: next-themes and CSS Variables
Wire up a rock-solid dark/light toggle with next-themes and Tailwind CSS variables — no flash, no mismatched server renders, works in Next.js App Router.
Why Most Dark Mode Implementations Break on First Load
Honestly, dark mode is one of those features that looks simple until you actually ship it. Then you get the dreaded flash of white — your user's dark-mode preference fires after hydration, so the page renders light for a split second before snapping to dark. It's jarring and it makes your app look unfinished.
The root cause is almost always the same: you're reading localStorage or prefers-color-scheme inside a React effect, which only runs client-side. By that point, the browser has already painted. The fix isn't complicated, but it requires a specific setup — one that involves injecting a tiny blocking script before your app renders.
This article walks through the full setup: CSS custom properties defined in Tailwind, next-themes for state management, and the correct way to wire your toggle button. No hand-waving, no skipping the hard parts.
Setting Up CSS Variables in Tailwind v4
Tailwind v4.0.2 changed how custom properties integrate with the utility system. Instead of extending tailwind.config.js, you define design tokens directly in your CSS using @theme. This is a significant shift — your token layer and your utility layer live in the same file.
Here's a minimal token setup that drives a full dark/light switch. Notice we're defining both the :root defaults and a .dark class override. The values use oklch() for perceptual consistency, which pairs well with the oklch color approach in Tailwind.
``css
@import 'tailwindcss';
@theme {
--color-bg: oklch(98% 0.01 255);
--color-surface: oklch(94% 0.015 255);
--color-text: oklch(12% 0.02 255);
--color-text-muted: oklch(45% 0.02 255);
--color-border: oklch(88% 0.012 255);
--color-accent: oklch(60% 0.19 255);
}
.dark {
--color-bg: oklch(12% 0.015 255);
--color-surface: oklch(17% 0.018 255);
--color-text: oklch(95% 0.01 255);
--color-text-muted: oklch(65% 0.015 255);
--color-border: oklch(28% 0.018 255);
--color-accent: oklch(72% 0.19 255);
}
``
With those variables defined, Tailwind's JIT engine generates utility classes like bg-(--color-bg) and text-(--color-text) automatically in v4. Earlier versions required explicit extend.colors entries — that's no longer needed. Just reference the variable name directly in your markup.
Installing next-themes and Wiring the Provider
Install next-themes with npm i next-themes. The package injects a blocking script into the HTML <head> that reads the saved preference before React boots — that's what kills the flash. You don't have to write that script yourself.
In Next.js App Router, you wrap your root layout with ThemeProvider. The attribute="class" prop tells next-themes to toggle the .dark class on <html>, which is exactly what your CSS variable overrides expect.
``tsx
// app/layout.tsx
import { ThemeProvider } from 'next-themes';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
``
The suppressHydrationWarning on <html> is required. next-themes modifies the class attribute server-side vs client-side and React would otherwise throw a hydration mismatch warning. It's not hiding a real bug — the library handles that difference intentionally. And disableTransitionOnChange prevents a weird mid-animation snap when switching themes rapidly.
Building the Toggle Button Component
Here's the thing: a lot of toggle implementations reach for useEffect and useState to track the current theme, then crash on SSR because window isn't defined. The correct pattern uses useTheme from next-themes and guards against the mounted state.
``tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
// Render a static placeholder to avoid layout shift
return <div className="w-9 h-9 rounded-lg bg-(--color-surface)" />;
}
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="w-9 h-9 rounded-lg flex items-center justify-center
bg-(--color-surface) border border-(--color-border)
text-(--color-text) hover:bg-(--color-bg)
transition-colors duration-150"
aria-label={Switch to ${theme === 'dark' ? 'light' : 'dark'} mode}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
``
The placeholder div that renders before mounting matches the button dimensions exactly — 36px by 36px. Without that, you get a content layout shift as the button pops in after hydration. Small detail, big difference in perceived quality.
If you want a three-way toggle (light / dark / system), just cycle through ['light', 'dark', 'system'] and display different icons. The resolvedTheme value from useTheme tells you the actual applied theme even when the stored preference is 'system', which is useful for picking the right icon.
Animating the Theme Transition Without Flash
You probably want a smooth color transition when the theme switches. The naive approach — CSS transition: all 0.2s on everything — causes that flash because transitions fire immediately on load before the class is set. That's why next-themes exposes disableTransitionOnChange.
A better pattern is to add the transition only after the page has loaded. You can do this with a tiny global CSS trick:
``css
/* Disable transitions on page load, re-enable after */
.no-transitions * {
transition: none !important;
}
/* Then in your tokens layer, add transitions to specific properties */
body {
background-color: var(--color-bg);
color: var(--color-text);
transition: background-color 200ms ease, color 200ms ease;
}
``
Alternatively, just use disableTransitionOnChange in the provider (as we did above) and accept that the switch is instant. Users don't actually notice the missing animation on intentional toggle clicks — they're clicking the button, so they expect an immediate response. Transitions matter more for system preference changes that happen in the background.
Combining next-themes with Glassmorphism and Transparent Surfaces
Glassmorphism effects are one of the trickier cases with dark/light toggling. Your rgba(255,255,255,0.15) backdrop that looks gorgeous in dark mode becomes nearly invisible on a light background. You need separate values per theme.
The cleanest way to handle this is — again — CSS variables. Define --color-glass-bg and --color-glass-border in both :root and .dark, and reference them in your component classes. This integrates naturally with the glassmorphism techniques we've covered elsewhere, and it means your components don't need any theme-awareness logic at all.
``css
:root {
--color-glass-bg: rgba(255, 255, 255, 0.6);
--color-glass-border: rgba(255, 255, 255, 0.3);
--color-glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
.dark {
--color-glass-bg: rgba(255, 255, 255, 0.08);
--color-glass-border: rgba(255, 255, 255, 0.12);
--color-glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
``
With those tokens in place, a glass card component just uses bg-(--color-glass-bg) and border-(--color-glass-border). The theme switch propagates automatically. No conditional class logic, no useTheme import inside your card — the CSS cascade handles everything. This is exactly why CSS variables are the right abstraction layer for theming.
Testing Your Toggle Across Edge Cases
What happens when a user has JavaScript disabled? Your CSS variables still work — the :root defaults apply. What happens on first visit with no saved preference? defaultTheme="system" reads prefers-color-scheme and applies the right class before paint. What about SSR with static export? next-themes handles that too, though you need to make sure enableSystem is true.
The one edge case that trips people up: Storybook. Storybook doesn't use your Next.js layout, so the ThemeProvider isn't wrapping your stories. You need to add a decorator in .storybook/preview.ts that wraps each story with ThemeProvider. It's a five-line change but easy to forget.
Worth checking component patterns in Tailwind for how to structure your component library so theme-aware styles stay predictable across your whole design system. The key is to never hardcode color values in component files — always go through the variable layer. That discipline pays off the first time you need to add a third theme (high contrast, brand colors, etc.).
Connecting the Toggle to Empire UI Components
Empire UI's 40 visual styles all use CSS custom properties internally, which means they respond to the .dark class automatically when you use this setup. You're not fighting the component library — you're extending the same token system it's already built on.
If you're using Empire UI's glassmorphism cards or gradient backgrounds, the theme toggle pattern for React applies directly. Drop the ThemeToggle component we built above into your navbar, wrap your root with ThemeProvider, and the whole UI switches cleanly. That's really it. The heavy lifting is in the CSS variable definitions, not in React component logic.
One thing worth knowing: if you're customizing Empire UI components with your own color overrides, make sure your custom values also live in CSS variables — not hardcoded hex values or Tailwind color utilities like text-gray-900. Those don't adapt to the dark class. Anything hardcoded stays the same across themes, which is almost never what you want.
FAQ
The most common cause is missing suppressHydrationWarning on the <html> element, or wrapping the ThemeProvider inside a client component that doesn't render on the server. Make sure ThemeProvider is in your root layout.tsx with attribute="class" and suppressHydrationWarning is on the <html> tag — not on <body>.
Yes. next-themes just toggles a class on <html> — it doesn't care about your Tailwind config at all. Define your dark overrides in a .dark { } block in your CSS file using @theme variables and it works exactly the same way.
next-themes saves the preference to localStorage automatically. As long as ThemeProvider is in your root layout (not a nested layout), the preference persists across client-side navigations. For full page reloads, the blocking script next-themes injects reads localStorage before React boots, so there's no flash.
You can't. Theme state lives in the browser's localStorage and is only available client-side. Server components always render with the default theme. Use the mounted guard pattern in client components to avoid mismatches, and design your server-rendered HTML to be theme-neutral (or default to light).
Call setTheme('system') — next-themes supports it out of the box. Use resolvedTheme (not theme) to get the actual applied theme for displaying icons, since resolvedTheme returns 'light' or 'dark' even when the stored value is 'system'.
Yes. In Tailwind v4, variables defined in @theme are exposed as design tokens and generate corresponding utilities. A variable --color-bg produces the bg-(--color-bg) utility class. You don't need to add entries to extend.colors like in v3 — the @theme block is the single source of truth.