Dark mode isn't optional anymore — users expect it. Tailwind CSS makes it straightforward with the dark: prefix, but there are pitfalls that catch even experienced developers. Here's how to do it right.
The Basics: How dark: Works
Tailwind's dark mode works by adding a dark class to the <html> element. Any class prefixed with dark: only applies when that class is present.
<!-- Light mode: white bg, dark text -->
<!-- Dark mode: dark bg, light text -->
<div class="bg-white dark:bg-neutral-950">
<h1 class="text-neutral-900 dark:text-white">Hello</h1>
<p class="text-neutral-600 dark:text-neutral-400">World</p>
</div>
The #1 Mistake: Dark-Only Styling
The most common bug: writing styles that only work in dark mode.
<!-- ❌ WRONG: This is invisible in light mode -->
<section class="bg-neutral-950">
<h1 class="text-white">Can't see this in light mode</h1>
</section>
<!-- ✅ RIGHT: Works in both modes -->
<section class="bg-white dark:bg-neutral-950">
<h1 class="text-neutral-900 dark:text-white">Visible everywhere</h1>
</section>
Every element needs both a light mode default and a dark mode variant. No exceptions.
Color Mapping Cheat Sheet
| Element | Light Mode | Dark Mode |
|---|---|---|
| Page background | bg-white | dark:bg-neutral-950 |
| Card background | bg-neutral-50 | dark:bg-neutral-900 |
| Primary text | text-neutral-900 | dark:text-white |
| Body text | text-neutral-600 | dark:text-neutral-400 |
| Muted text | text-neutral-500 | dark:text-neutral-400 |
| Borders | border-neutral-200 | dark:border-neutral-800 |
| Accent text | text-indigo-600 | dark:text-indigo-400 |
| Input background | bg-white | dark:bg-neutral-900 |
Strategy 1: Class-Based Toggle (Recommended)
Add/remove the dark class on <html> based on user preference. This gives users control.
// Toggle dark mode
document.documentElement.classList.toggle('dark');
// Check current mode
const isDark = document.documentElement.classList.contains('dark');
// Persist preference
localStorage.setItem('theme', isDark ? 'dark' : 'light');
Strategy 2: System Preference
Respect the user's OS-level dark mode setting automatically.
// Check system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Apply on load
if (prefersDark) {
document.documentElement.classList.add('dark');
}
// Listen for changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
document.documentElement.classList.toggle('dark', e.matches);
});
Strategy 3: Both (Best UX)
Default to system preference, but let users override. Store the override in localStorage.
// On page load
const stored = localStorage.getItem('theme');
if (stored) {
document.documentElement.classList.toggle('dark', stored === 'dark');
} else {
// Fall back to system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
}
Common Pitfalls
- White text on white background — Always pair
text-whitewith a dark background or usetext-neutral-900 dark:text-white - Forgetting borders —
border-neutral-200 dark:border-neutral-800is easy to miss - Images without adaptation — Consider
dark:opacity-80ordark:brightness-90for images that are too bright in dark mode - Flash of wrong theme — Load the theme preference in a
<script>in<head>(before render) to prevent the flash
💡 Pro tip: Bookmark the color mapping table above. You'll reference it on every project. Or better yet, browse OpenTailwind's blocks — every one has proper light/dark mode support you can learn from.

