How to Implement Dark Mode in Your App (2026)
Dark mode is expected in 2026. Here's how to implement it properly — with system preference detection, manual toggle, and persistence.
The Simple Way: Tailwind CSS + next-themes
npm install next-themes
Provider Setup
// app/layout.tsx
import { ThemeProvider } from 'next-themes'
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}
Theme Toggle
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null // Avoid hydration mismatch
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
)
}
Use Dark Mode Classes
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
<h1 className="text-gray-900 dark:text-gray-100">Hello</h1>
<p className="text-gray-600 dark:text-gray-400">Content</p>
<button className="bg-blue-600 dark:bg-blue-500 text-white">Click</button>
</div>
That's it. System preference detection, manual toggle, and persistence — handled by next-themes.
The CSS Variables Way (Framework-Agnostic)
Define Variables
/* globals.css */
:root {
--bg: #ffffff;
--text: #1a1a1a;
--text-secondary: #666666;
--border: #e5e5e5;
--accent: #3b82f6;
--card: #f9fafb;
}
[data-theme="dark"] {
--bg: #0a0a0a;
--text: #fafafa;
--text-secondary: #a3a3a3;
--border: #262626;
--accent: #60a5fa;
--card: #171717;
}
body {
background: var(--bg);
color: var(--text);
}
Detect System Preference
// Check system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
// Listen for changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
document.documentElement.dataset.theme = e.matches ? 'dark' : 'light'
})
Persist Choice
// Save preference
function setTheme(theme) {
document.documentElement.dataset.theme = theme
localStorage.setItem('theme', theme)
}
// Load on page load (add to <head> to avoid flash)
const saved = localStorage.getItem('theme')
const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
document.documentElement.dataset.theme = saved || system
Preventing Flash of Wrong Theme
The biggest dark mode bug: white flash on page load. Fix with an inline script in <head>:
<head>
<script>
(function() {
const saved = localStorage.getItem('theme')
const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
document.documentElement.classList.add(saved || system)
})()
</script>
</head>
This runs before the page renders, preventing any flash. next-themes handles this automatically.
shadcn/ui Dark Mode
shadcn/ui uses CSS variables that automatically support dark mode:
/* Already in shadcn's globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}
}
All shadcn components automatically use these variables.
Design Tips for Dark Mode
-
Don't just invert colors. Pure white on pure black (#fff on #000) is harsh. Use off-white on dark gray (#fafafa on #0a0a0a).
-
Reduce contrast slightly. Text in dark mode should be ~87% opacity, not 100%.
-
Adjust shadows. Shadows don't work on dark backgrounds. Use subtle borders instead.
-
Test images. Some images look bad on dark backgrounds. Add rounded corners and subtle borders.
-
Adjust brand colors. Some brand colors need lightening for dark mode accessibility.
FAQ
Should every app have dark mode?
In 2026, yes. Users expect it. next-themes makes it trivial to implement.
CSS variables vs Tailwind dark: classes?
Both work. Tailwind dark: is more explicit and co-located with components. CSS variables are cleaner for large design systems.
What about dark mode in emails?
Email clients apply their own dark mode. Use transparent backgrounds and high-contrast text. Test in Gmail, Apple Mail, and Outlook dark modes.
Bottom Line
Use next-themes + Tailwind for Next.js (5 minutes to implement). Use CSS variables for framework-agnostic apps. Key: prevent the flash of wrong theme with an inline <head> script, persist user choice in localStorage, and respect system preference by default.