UI/UX & Design Engineering
Dark Mode Implementation in Next.js with Tailwind CSS
Last updated: April 14, 2026
TL;DR
Dark mode is not a CSS gimmick — it's a user experience feature that affects readability, battery life, and accessibility. iamuvin.com runs dark-only because the brand demands it. But for every client project I build, I implement a proper dark/light toggle using Tailwind CSS v4's class-based dark mode with CSS custom properties. The hardest problem is not the styling — it's preventing the flash of wrong theme on page load, which requires a blocking script in the document head before React hydrates. This guide covers the full implementation: CSS variable architecture, a React Context-based Theme Provider, localStorage persistence, system preference detection with prefers-color-scheme, and the design considerations that determine whether a project should support both modes or commit to one. Every code sample is from production Next.js applications.
Class-Based vs Media Query
Tailwind CSS offers two strategies for dark mode, and the choice matters more than most developers realize.
Media query (@media (prefers-color-scheme: dark)) respects the user's operating system setting automatically. No JavaScript required. It works by default in Tailwind v4 — just use the dark: variant and the browser handles the rest. The appeal is simplicity: zero runtime code, zero state management, zero persistence logic.
The problem? Users can't override it. If someone prefers dark mode on their OS but wants your app in light mode (or vice versa), they're stuck. For marketing sites and landing pages where you control the aesthetic, media query works fine. For applications where users spend hours, it's insufficient.
Class-based dark mode toggles the dark class on the <html> element. When <html class="dark"> is present, all dark: variants activate. When it's absent, light mode renders. This gives you full programmatic control — users can toggle manually, and you can persist their choice.
/* Tailwind v4 — enable class-based dark mode */
@custom-variant dark (&:where(.dark, .dark *));I use class-based for every client project. The media query approach is a reasonable default for simple sites, but the moment you need user control, you need class-based. And in my experience, that moment comes sooner than you think.
CSS Variables for Theme Colors
Hard-coding colors in dark: variants works for small projects, but it creates a maintenance problem at scale. When you have 40 components each specifying bg-white dark:bg-gray-900, changing your dark background means touching 40 files.
The solution is CSS custom properties that change based on the active theme:
/* app/globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-bg-primary: var(--bg-primary);
--color-bg-surface: var(--bg-surface);
--color-bg-elevated: var(--bg-elevated);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-muted: var(--text-muted);
--color-border-default: var(--border-default);
--color-accent: var(--accent);
}
:root {
--bg-primary: #ffffff;
--bg-surface: #f8fafc;
--bg-elevated: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--border-default: #e2e8f0;
--accent: #f7931a;
}
.dark {
--bg-primary: #0a0e1a;
--bg-surface: #111827;
--bg-elevated: #1a2236;
--text-primary: #ffffff;
--text-secondary: #c9d1e0;
--text-muted: #6b7fa3;
--border-default: #1e293b;
--accent: #f7931a;
}Now your components use semantic tokens:
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="bg-bg-surface border border-border-default rounded-2xl p-6">
<p className="text-text-primary">{children}</p>
</div>
);
}No dark: prefixes anywhere in your component code. The CSS variables handle the switch. This scales beautifully — I've used this pattern on projects with 100+ components and theme changes require editing exactly one file.
The accent color stays the same across themes in the example above. That's intentional. Brand colors often work in both modes. But if your accent needs adjustment for contrast, the variable system handles that too — just set a different value in the .dark block.
The Flash of Wrong Theme — Preventing It
This is the problem that trips up every developer implementing dark mode in a server-rendered framework like Next.js. Here's the sequence that causes it:
- User selects dark mode. You save
"dark"tolocalStorage. - User refreshes the page.
- Server renders HTML with no
darkclass (the server has no access tolocalStorage). - Browser paints the light-mode HTML. User sees a white flash.
- React hydrates, your Theme Provider reads
localStorage, adds thedarkclass. - Page re-paints in dark mode.
That white flash between steps 4 and 5 is jarring. It lasts anywhere from 50ms to 500ms depending on the device, and it makes the entire theme system feel broken.
The fix is a blocking inline script in the <head> that runs before the browser paints. This script reads localStorage and applies the dark class synchronously, before any content renders:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch(e) {}
})();
`,
}}
/>
</head>
<body className="bg-bg-primary text-text-primary">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}Key details:
- `suppressHydrationWarning` on
<html>prevents React from complaining about the class mismatch between server render and client hydrate. The server renders withoutdark, but the blocking script may add it before hydration. Without this attribute, you'll get a console warning on every page load. - `dangerouslySetInnerHTML` is necessary because this script must be inline and blocking. External scripts or
defer/asyncscripts run too late. - The `try/catch` handles environments where
localStorageis unavailable (incognito mode in some browsers, SSR edge cases). - The fallback to `matchMedia` ensures first-time visitors get a theme that matches their system preference.
This pattern eliminates the flash completely. The browser never paints the wrong theme because the class is applied before the first paint. I've used this on every Next.js project that supports theme toggling, and it works reliably across Chrome, Firefox, Safari, and Edge.
Theme Provider with React Context
With the flash problem solved, the Theme Provider handles the interactive toggle and state management. This is a client component:
// components/theme-provider.tsx
"use client";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
} from "react";
type Theme = "light" | "dark" | "system";
interface ThemeContextValue {
theme: Theme;
resolvedTheme: "light" | "dark";
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("system");
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
const applyTheme = useCallback((resolved: "light" | "dark") => {
setResolvedTheme(resolved);
if (resolved === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}, []);
const setTheme = useCallback(
(newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem("theme", newTheme);
if (newTheme === "system") {
const systemDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
applyTheme(systemDark ? "dark" : "light");
} else {
applyTheme(newTheme);
}
},
[applyTheme]
);
const toggleTheme = useCallback(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
}, [resolvedTheme, setTheme]);
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
const initial = stored || "system";
setThemeState(initial);
if (initial === "system") {
const systemDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
setResolvedTheme(systemDark ? "dark" : "light");
} else {
setResolvedTheme(initial === "dark" ? "dark" : "light");
}
}, []);
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
applyTheme(e.matches ? "dark" : "light");
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme, applyTheme]);
return (
<ThemeContext.Provider
value={{ theme, resolvedTheme, setTheme, toggleTheme }}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}The provider exposes four values: theme (what the user selected — could be "system"), resolvedTheme (the actual "light" or "dark" being rendered), setTheme (for explicit selection), and toggleTheme (for a simple toggle button).
A toggle button using this provider:
"use client";
import { useTheme } from "@/components/theme-provider";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle() {
const { resolvedTheme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="rounded-lg p-2 hover:bg-bg-elevated transition-colors"
aria-label={`Switch to ${resolvedTheme === "dark" ? "light" : "dark"} mode`}
>
{resolvedTheme === "dark" ? (
<Sun className="h-5 w-5 text-text-secondary" />
) : (
<Moon className="h-5 w-5 text-text-secondary" />
)}
</button>
);
}Notice the aria-label — it tells screen reader users what the button will do, not what icon is currently shown. Small detail, significant accessibility impact.
Tailwind v4 Dark Mode Config
Tailwind CSS v4 moved to a CSS-first configuration model. There's no tailwind.config.js for dark mode anymore. The entire setup lives in your CSS file:
/* app/globals.css */
@import "tailwindcss";
/* Enable class-based dark mode */
@custom-variant dark (&:where(.dark, .dark *));That single line is the entire Tailwind v4 dark mode configuration. The @custom-variant directive tells Tailwind that dark: utilities should activate when the element is inside a .dark context. The :where() wrapper keeps specificity at zero, so dark mode styles don't accidentally override other styles through specificity wars.
If you're migrating from Tailwind v3, you can remove the darkMode: 'class' option from your old tailwind.config.js. The CSS-first approach is cleaner and eliminates a configuration file dependency.
For projects using the CSS variable approach I described above, you rarely need the dark: variant in your component code at all. But when you do need it — for one-off overrides or components that don't fit the token system — it works exactly as before:
<div className="bg-white dark:bg-slate-900 border dark:border-slate-700">
<p className="text-gray-900 dark:text-gray-100">
Direct dark: variants still work when you need them.
</p>
</div>I recommend the CSS variable approach as the primary strategy and dark: variants as the escape hatch for edge cases. Mixing both is fine — the systems are fully compatible.
Persisting User Preference
The Theme Provider above stores the user's choice in localStorage. This is the right default for most applications — it's synchronous (critical for the blocking script), available without authentication, and survives browser restarts.
But there are nuances worth considering:
Across devices: localStorage is device-specific. If a user selects dark mode on their laptop, they'll still see light mode on their phone. For authenticated applications, consider syncing the theme preference to your database:
async function setThemeWithSync(newTheme: Theme) {
setTheme(newTheme);
if (session?.user) {
await fetch("/api/user/preferences", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: newTheme }),
});
}
}Cookie-based persistence is another option. Cookies are sent with every request, so the server can read the theme and render the correct HTML without a blocking script. This eliminates the need for dangerouslySetInnerHTML entirely:
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const theme = request.cookies.get("theme")?.value;
const response = NextResponse.next();
// Pass theme to the layout via a header
if (theme) {
response.headers.set("x-theme", theme);
}
return response;
}The cookie approach is elegant but has trade-offs: cookies are sent on every request (adding bytes), they require server-side handling, and they don't work with static exports. For most Next.js applications, I stick with localStorage plus the blocking script. It's simpler and works universally.
System Preference Detection
Respecting the user's operating system preference is table stakes for a professional dark mode implementation. The prefers-color-scheme media query makes this straightforward:
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;But detection at load time is only half the story. Users change their system theme — macOS and Windows both support automatic light/dark switching based on time of day. Your application should respond in real time when the user's theme is set to "system":
useEffect(() => {
if (theme !== "system") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
applyTheme(e.matches ? "dark" : "light");
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme, applyTheme]);This listener fires when the OS theme changes. If the user has selected "system" as their preference in your app, the theme switches automatically. If they've explicitly chosen "light" or "dark," the listener is not attached — their manual choice takes priority.
The priority chain I implement is: explicit user choice > system preference > your application default. This means first-time visitors see a theme matching their OS, returning visitors see whatever they last selected, and only if both sources are missing does the application default kick in (which should be light mode — it's the safer assumption for readability).
Dark Mode Design Considerations
Implementing dark mode is not "invert the colors." That approach produces ugly, unreadable interfaces. Here are the design principles I follow:
Don't use pure black. #000000 backgrounds create too much contrast with white text, causing eye strain during extended reading. I use #0a0e1a (a deep navy-black) for primary backgrounds and #111827 for surface-level elements. The slight color temperature adds depth without reducing readability.
Reduce white intensity. Pure #ffffff text on dark backgrounds is harsh. I use #ffffff only for headings and primary text, dropping to #c9d1e0 for body text and #6b7fa3 for muted text. This creates a comfortable hierarchy.
Elevation through lightness. In light mode, elevated elements (cards, modals, dropdowns) are distinguished by shadows. In dark mode, shadows are invisible against dark backgrounds. Instead, use progressively lighter surface colors: background (#0a0e1a) < surface (#111827) < elevated (#1a2236). Each step up the elevation scale gets slightly lighter.
Adjust image brightness. Full-brightness images on a dark background feel glaring. A subtle CSS filter handles this:
.dark img:not([data-no-dim]) {
filter: brightness(0.9);
}The data-no-dim attribute lets you opt specific images out — logos and illustrations that need full brightness can skip the filter.
Test with real content. A dark theme that looks good with placeholder text may fail with real data. Long-form content, dense tables, form-heavy pages — test all of these. I've caught contrast issues in dark mode that only appeared with real product data, not the clean demo content used during development.
Contrast and Accessibility
Dark mode doesn't exempt you from WCAG contrast requirements. The 4.5:1 ratio for normal text and 3:1 for large text apply regardless of the color scheme.
Here's where developers get caught: a color that passes contrast checks in light mode may fail in dark mode, and vice versa. Your muted gray that works on white might disappear on a dark surface. You need to check contrast ratios for every theme independently.
The colors I use are pre-checked:
| Role | Light Mode | Dark Mode | Contrast (Light) | Contrast (Dark) |
|---|---|---|---|---|
| Primary text | #0f172a on #ffffff | #ffffff on #0a0e1a | 17.4:1 | 18.1:1 |
| Secondary text | #475569 on #ffffff | #c9d1e0 on #0a0e1a | 5.9:1 | 10.8:1 |
| Muted text | #94a3b8 on #ffffff | #6b7fa3 on #0a0e1a | 3.3:1 | 4.3:1 |
| Accent | #f7931a on #ffffff | #f7931a on #0a0e1a | 2.5:1 | 6.4:1 |
Notice the accent color (#f7931a) fails contrast on white but passes on dark. This is common with warm accent colors. In light mode, I never use the accent as text on white backgrounds — it's reserved for buttons, badges, and decorative elements where the 3:1 non-text contrast ratio applies.
Focus indicators need extra attention in dark mode. The default browser focus ring (usually blue) may not be visible on dark backgrounds. I enforce a custom focus style that works in both themes:
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}Using the accent CSS variable ensures the focus ring color adapts if the accent changes between themes. The 2px offset prevents the ring from being clipped by overflow: hidden on parent elements.
When Dark-Only Is the Right Choice
iamuvin.com is dark-only. No toggle. No light mode. That's a deliberate design decision, not laziness.
Dark-only makes sense when:
- Your brand identity is built on dark aesthetics. iamuvin.com's visual language — glowing orange accents on deep space backgrounds, code typography, gradient meshes — loses its identity in light mode. The dark palette is the brand.
- Your audience expects it. Developer tools, crypto dashboards, gaming platforms, creative portfolios — users in these spaces default to dark mode. A light mode would feel out of place.
- You want maximum visual impact. Dark interfaces make colors pop. Photography portfolios, design showcases, and product launches often benefit from dark backgrounds that don't compete with the content.
- You're optimizing for a specific experience. Media consumption (video, images, reading) is often more comfortable on dark backgrounds, especially in low-light environments.
Dark-only is not the right choice when:
- You're building a productivity tool. Users spending 8+ hours in your application need the option. Some people genuinely read faster on light backgrounds.
- Your content is text-heavy. Long-form reading (documentation, articles, reports) is more comfortable for most people in light mode. Research consistently shows higher reading comprehension with dark text on light backgrounds for extended sessions.
- Your user base is broad. E-commerce, SaaS dashboards, news sites — these serve diverse users with diverse preferences. Give them the toggle.
- Accessibility is a hard requirement. While dark mode can be fully accessible, light mode has a longer history of established contrast patterns. Some users with visual impairments specifically need light mode.
For client projects I build through iamuvin.com, the default recommendation is always both modes with a toggle. Dark-only is reserved for projects where the brand justifies it and the audience expects it. The implementation cost of supporting both is minimal with the CSS variable architecture — the design cost of getting both modes right is where the real work lives.
Key Takeaways
- Use class-based dark mode in Tailwind v4 with
@custom-variant dark (&:where(.dark, .dark *)). It gives you programmatic control over the theme. - CSS variables are the scaling strategy. Define semantic color tokens that change based on
.darkclass. Your components reference tokens, not raw colors. - The flash of wrong theme is solved with a blocking script in the
<head>that readslocalStoragebefore the first paint. This is non-negotiable for server-rendered apps. - Build a three-option system: light, dark, and system. System respects
prefers-color-schemeand responds to real-time OS changes. - Persist preferences in `localStorage` for simplicity. Sync to a database for cross-device consistency in authenticated apps.
- Dark mode is not inverted light mode. Use deep navies instead of pure black, reduce text brightness, and communicate elevation through surface lightness rather than shadows.
- Check contrast ratios independently for each theme. A color that passes WCAG in light mode may fail in dark mode.
- Dark-only is valid when your brand, audience, and content type justify it. For most client projects, support both modes.
The complete implementation — CSS variables, blocking script, Theme Provider, toggle component — is under 150 lines of code. The engineering effort is minimal. The design effort of making both themes look intentional rather than auto-generated is where you should invest your time.
*I'm Uvin Vindula — a Web3 and AI engineer building from Sri Lanka and the UK. I ship production Next.js applications, smart contracts, and AI-powered products. iamuvin.com runs dark-only because the brand demands it, but every client project gets a theme system built to this standard. If you're building something that needs to look exceptional in every lighting condition, let's talk.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.