How to Add Dark Mode to Any Website (2026 Guide)
Dark mode is no longer optional. 82% of users prefer dark mode on at least some apps, and it's an accessibility feature for users with light sensitivity. Here's how to implement it properly.
The Quick Version
If you're using Tailwind CSS + Next.js, the fastest path:
npm install next-themes
// app/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
)
}
// tailwind.config.ts
export default {
darkMode: 'class',
// ...
}
/* Use Tailwind dark: prefix */
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
Done. But if you want to understand what's happening or aren't using Tailwind, read on.
Approach 1: CSS Media Query (System Preference)
The simplest approach — respect the user's OS dark mode setting automatically.
:root {
--bg: #ffffff;
--text: #1a1a1a;
--surface: #f5f5f5;
--border: #e0e0e0;
--primary: #2563eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--text: #e5e5e5;
--surface: #1a1a1a;
--border: #2a2a2a;
--primary: #60a5fa;
}
}
body {
background-color: var(--bg);
color: var(--text);
}
Pros: Zero JavaScript. Works everywhere. Automatic. Cons: No user toggle. Users can't override their system preference per-site.
Approach 2: CSS Variables + Class Toggle
Add a manual toggle while still supporting system preference.
/* Light theme (default) */
:root {
--bg: #ffffff;
--text: #1a1a1a;
--surface: #f5f5f5;
--border: #e0e0e0;
}
/* Dark theme */
:root.dark {
--bg: #0a0a0a;
--text: #e5e5e5;
--surface: #1a1a1a;
--border: #2a2a2a;
}
// Theme toggle
function toggleTheme() {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
// On page load: check saved preference, then system preference
function initTheme() {
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.classList.toggle('dark', saved === 'dark');
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
}
initTheme();
Pros: User control. Persists across visits. Falls back to system preference. Cons: Flash of light theme on page load (FOUC) if JavaScript loads late.
Solving the Flash (FOUC)
The biggest dark mode problem: the page loads white, then snaps to dark. Users hate this.
Solution: Inline Script in <head>
Add a blocking script before any CSS renders:
<head>
<script>
(function() {
var saved = localStorage.getItem('theme');
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<!-- CSS loads after this -->
</head>
This runs synchronously before the page renders, so the correct theme is applied immediately.
Next.js Solution
next-themes handles FOUC automatically by injecting a script into <head>:
import { ThemeProvider } from 'next-themes'
// In your root layout
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
Approach 3: Tailwind CSS Dark Mode
Tailwind makes dark mode straightforward with the dark: variant.
// tailwind.config.ts
export default {
darkMode: 'class', // or 'media' for system-only
}
<div class="bg-white dark:bg-slate-900">
<h1 class="text-gray-900 dark:text-white">Title</h1>
<p class="text-gray-600 dark:text-gray-300">Content</p>
<button class="bg-blue-600 dark:bg-blue-500 text-white">Action</button>
</div>
Tips for Tailwind dark mode:
- Use semantic color names in your config for consistency
- Consider a theme config object to avoid repeating
dark:everywhere - Use
@applyin component classes for frequently repeated patterns
Design Guidelines
Color Choices
Don't just invert colors. Dark mode needs intentional design:
| Element | Light | Dark | Notes |
|---|---|---|---|
| Background | #ffffff | #0a0a0a or #121212 | Pure black (#000) is too harsh |
| Surface | #f5f5f5 | #1a1a1a or #1e1e1e | Cards, modals |
| Text (primary) | #1a1a1a | #e5e5e5 | Not pure white (#fff) |
| Text (secondary) | #6b7280 | #9ca3af | Slightly lighter than light mode |
| Borders | #e5e7eb | #2d2d2d | Subtle separation |
| Primary accent | #2563eb | #60a5fa | Lighter in dark mode |
Common Mistakes
-
Pure black background. Use
#0a0a0aor#121212instead of#000000. Pure black creates too much contrast and causes eye strain. -
Pure white text. Use
#e5e5e5or#f0f0f0instead of#ffffff. Slightly off-white is easier to read on dark backgrounds. -
Same shadows. Dark mode shadows should be darker/more subtle. Consider using lighter borders instead of shadows in dark mode.
-
Forgetting images. Bright images can be jarring in dark mode. Consider reducing brightness slightly:
.dark img { filter: brightness(0.9); }
- Ignoring contrast ratios. Both themes need WCAG AA contrast (4.5:1 for body text). Test with a contrast checker.
Three-Way Toggle (Light / Dark / System)
Many users want three options: light, dark, and auto (follow system).
type Theme = 'light' | 'dark' | 'system';
function setTheme(theme: Theme) {
localStorage.setItem('theme', theme);
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', isDark);
} else {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
}
// Listen for system preference changes (when set to 'system')
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem('theme') === 'system') {
document.documentElement.classList.toggle('dark', e.matches);
}
});
Framework-Specific Guides
Next.js (App Router)
Use next-themes. It handles SSR, FOUC prevention, and system preference automatically.
Astro
Use the <script> approach in your base layout's <head>. Astro's island architecture means you can use any UI framework's toggle component.
Vue / Nuxt
Use @vueuse/core's useColorMode composable. It handles persistence and system detection.
Vanilla HTML/CSS
Use Approach 2 (CSS variables + class toggle) with the FOUC-preventing inline script.
Testing Dark Mode
- Visual inspection. Switch between modes and check every page
- Contrast ratios. Use Chrome DevTools accessibility panel
- System preference. Test with OS dark mode toggle
- Persistence. Reload the page — does it remember your choice?
- No flash. Hard-refresh (Cmd+Shift+R) — does the correct theme load instantly?
- Images and media. Do images, videos, and embeds look appropriate in both modes?
FAQ
Should dark mode be the default?
Default to system preference (prefers-color-scheme). This respects the user's existing choice without forcing either mode.
Does dark mode affect SEO?
No. Dark mode is purely visual (CSS). Search engines see the same content regardless of theme.
Does dark mode save battery?
On OLED/AMOLED screens, yes — dark pixels are literally turned off. On LCD screens, no meaningful difference.
How do I handle third-party embeds in dark mode?
Many embeds (YouTube, Twitter, code blocks) have dark mode parameters. For others, use CSS filter: invert(1) as a last resort, or wrap in a light-background container.
The Bottom Line
For most projects in 2026:
- Tailwind + next-themes if using Next.js (zero FOUC, minimal code)
- CSS variables + class toggle for everything else
- Always prevent FOUC with an inline
<head>script - Default to system preference with a manual override
- Don't use pure black — use
#0a0a0aor#121212
Dark mode is a 30-minute feature that makes your site feel 10x more polished.