Dark Mode Strategies in Tailwind CSS
Master dark mode in Tailwind CSS with class strategy, media strategy, custom toggles, CSS variable theming, and flash-free server-side approaches.
What you'll learn
- ✓How the class and media dark mode strategies differ under the hood
- ✓How to build a flash-free theme toggle with localStorage
- ✓How to use CSS variables so you never write dark: on every element
- ✓How to support three or more themes with data attributes
- ✓How to handle images, SVGs, and accessibility in dark mode
Prerequisites
- •A working Tailwind CSS project (v3.4+)
- •Basic understanding of CSS custom properties
Dark mode is no longer a nice-to-have. Users expect it, operating systems default to it, and apps that ignore it feel broken at night. Tailwind CSS ships two strategies for dark mode, and a third hybrid approach using CSS variables gives you the most flexibility of all. This article walks through each one with real code you can drop into a project today.
The Two Built-In Strategies
Tailwind exposes a darkMode key in your config. It accepts two values: 'media' and 'class'.
darkMode: 'media'
-> dark:bg-slate-900 compiles to @media (prefers-color-scheme: dark) { ... }
-> User has NO control. OS decides.
darkMode: 'class'
-> dark:bg-slate-900 compiles to .dark .bg-slate-900 { ... }
-> You toggle the .dark class on <html>. User has full control. Media Strategy
The simplest option. Zero JavaScript required.
// tailwind.config.js
export default {
darkMode: 'media',
content: ['./src/**/*.{html,js,jsx,tsx,astro}'],
theme: { extend: {} },
};
Now any dark: utility follows the OS preference automatically:
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<h1 class="text-2xl font-bold">Hello, world</h1>
<p class="text-gray-600 dark:text-gray-400">
This text adapts to your OS theme automatically.
</p>
</div>
The upside is zero maintenance. The downside is the user cannot override it. If someone has their OS set to light mode but prefers your app in dark mode, they are stuck.
Class Strategy
This is what most production apps use.
// tailwind.config.js
export default {
darkMode: 'class',
content: ['./src/**/*.{html,js,jsx,tsx,astro}'],
theme: { extend: {} },
};
The dark: variants now only activate when an ancestor element (typically <html>) has the dark class. You control when that class appears.
<html class="dark">
<body class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">
<button class="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 text-white px-4 py-2 rounded">
Subscribe
</button>
</body>
</html>
Building a Flash-Free Toggle
The biggest mistake developers make is loading the theme preference after the page paints. The user sees a white flash before dark mode kicks in. Fix this by inlining a tiny script in the <head>, before any stylesheets render.
<head>
<script>
(function () {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = saved === 'dark' || (!saved && prefersDark);
document.documentElement.classList.toggle('dark', dark);
})();
</script>
<!-- stylesheets go here -->
</head>
This script runs synchronously before the first paint. It checks localStorage first, falls back to the OS preference, and sets the class immediately.
The toggle button:
<button id="theme-toggle" class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700" aria-label="Toggle dark mode">
<span class="dark:hidden">🌙</span>
<span class="hidden dark:inline">☀️</span>
</button>
<script>
const toggle = document.getElementById('theme-toggle');
toggle.addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
</script>
For a three-way toggle (light, dark, system), store 'system' as a third value and listen for OS changes:
function applyTheme(preference) {
if (preference === 'system') {
const osDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', osDark);
} else {
document.documentElement.classList.toggle('dark', preference === 'dark');
}
localStorage.setItem('theme', preference);
}
// Listen for OS 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);
}
});
CSS Variable Approach: Write dark: Once
Sprinkling dark: on every utility gets tedious fast. A better approach defines semantic color tokens as CSS variables, then swaps them with a single class.
/* globals.css */
@layer base {
:root {
--color-bg: 255 255 255;
--color-fg: 15 23 42;
--color-surface: 248 250 252;
--color-border: 226 232 240;
--color-accent: 59 130 246;
}
.dark {
--color-bg: 15 23 42;
--color-fg: 241 245 249;
--color-surface: 30 41 59;
--color-border: 51 65 85;
--color-accent: 96 165 250;
}
}
Wire them into your Tailwind config:
// tailwind.config.js
export default {
darkMode: 'class',
theme: {
extend: {
colors: {
bg: 'rgb(var(--color-bg) / <alpha-value>)',
fg: 'rgb(var(--color-fg) / <alpha-value>)',
surface: 'rgb(var(--color-surface) / <alpha-value>)',
border: 'rgb(var(--color-border) / <alpha-value>)',
accent: 'rgb(var(--color-accent) / <alpha-value>)',
},
},
},
};
Now your markup is theme-agnostic:
<body class="bg-bg text-fg">
<div class="bg-surface border border-border rounded-lg p-6">
<h2 class="text-xl font-semibold">Dashboard</h2>
<p class="text-fg/70 mt-2">Your metrics for today.</p>
<button class="bg-accent text-white px-4 py-2 rounded mt-4">
View report
</button>
</div>
</body>
No dark: variants anywhere in the template. Toggle the .dark class and every color updates automatically.
Custom Selector with Data Attributes
For more than two themes, use a custom selector:
// tailwind.config.js
export default {
darkMode: ['class', '[data-theme="dark"]'],
};
Now define as many themes as you need:
[data-theme="dark"] {
--color-bg: 15 23 42;
--color-fg: 241 245 249;
}
[data-theme="sepia"] {
--color-bg: 253 246 227;
--color-fg: 92 64 51;
}
[data-theme="high-contrast"] {
--color-bg: 0 0 0;
--color-fg: 255 255 255;
}
Set the attribute on <html>:
<html data-theme="sepia">
Handling Images and SVGs
White logos disappear on white backgrounds. Dark screenshots look washed out on dark backgrounds. Handle these cases:
<!-- Swap images per theme -->
<img src="/logo-dark.svg" class="hidden dark:block" alt="Logo" />
<img src="/logo-light.svg" class="block dark:hidden" alt="Logo" />
<!-- Invert a decorative image -->
<img src="/diagram.png" class="dark:invert dark:hue-rotate-180" alt="Architecture" />
<!-- Use currentColor in SVGs -->
<svg class="w-6 h-6 text-fg" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L2 22h20L12 2z" />
</svg>
Accessibility Checklist
Dark mode is an accessibility feature. Do it right:
- Contrast ratios — Maintain at least 4.5:1 for body text and 3:1 for large text in both modes. Test with browser DevTools.
- Focus indicators —
focus:ring-2 focus:ring-accentshould be visible on both light and dark backgrounds. - Color is not the only indicator — If you use red for errors, also use an icon or text label. Red on dark gray can be hard to distinguish.
- Respect
prefers-reduced-motion— If you animate the theme transition, wrap it inmotion-safe:. - Test with screen readers — The
aria-labelon your toggle button should say what it does, not what it looks like.
Server-Side Rendering Considerations
In SSR frameworks like Next.js or Astro, you can read a cookie on the server to set the class before HTML is sent to the browser. This eliminates the flash entirely without any inline script:
// Next.js middleware example
import { NextResponse } from 'next/server';
export function middleware(request) {
const theme = request.cookies.get('theme')?.value || 'light';
const response = NextResponse.next();
// Set a header the layout can read
response.headers.set('x-theme', theme);
return response;
}
In your layout, apply the class based on the header or cookie value.
Wrap-Up
Use the class strategy for user-controlled themes. Use CSS variables to avoid scattering dark: variants across every component. Inline a bootstrap script in the <head> to prevent flash. For three or more themes, switch to data attributes. Test contrast in both modes, swap images that do not adapt, and treat dark mode as the accessibility feature it is.
Related articles
- Tailwind Tailwind Dark Mode Strategies: Class, Media, and CSS Variables
Compare the class-based, media-based, and variable-driven approaches to dark mode in Tailwind, with code and the trade-offs of each.
- Tailwind Design Tokens and Theming with Tailwind CSS
Build a robust theming system using custom design tokens, CSS variables, theme extension, and multi-brand support in Tailwind CSS.
- Tailwind Customizing Tailwind: Theme and Design Tokens
Extend Tailwind's theme with custom colors, spacing, and design tokens that scale across a real product without fighting the framework.
- Tailwind Responsive Design Patterns with Tailwind CSS
Master mobile-first breakpoints, container queries, responsive grids, and real-world layout patterns using Tailwind CSS utilities.