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.
What you'll learn
- ✓How Tailwind dark mode works under the hood
- ✓Pros and cons of media vs class strategies
- ✓How to wire a user-controlled theme toggle
- ✓How to use CSS variables for tokens that swap
- ✓How to avoid the flash of wrong theme on load
Prerequisites
- •A project using Tailwind CSS
What and Why
Dark mode is no longer a novelty; users expect it. Tailwind ships first-class support, but the choice between strategies has real implications: who decides the theme, how it persists, whether the OS preference is honored, and how easy it is to extend to more themes later.
Pick the right strategy up front and you save a refactor; pick the wrong one and you fight your CSS every time you add a component.
Mental Model
Tailwind’s dark: variant is just a conditional selector. The darkMode setting in your config controls how that condition is expressed.
darkMode: 'media' -> dark:* applies when @media (prefers-color-scheme: dark)
darkMode: 'class' -> dark:* applies when an ancestor has the .dark class
darkMode: ['class', '[data-theme="dark"]'] -> custom selector media follows the OS, period. class lets the user override the OS via a toggle. Most modern apps choose class because users want control.
Hands-on Example
Configure class strategy:
// tailwind.config.js
export default {
darkMode: 'class',
content: ['./src/**/*.{astro,tsx,html}'],
theme: { extend: {} },
};
Use the dark: variant in markup:
<body class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
<button class="bg-blue-600 text-white hover:bg-blue-700
dark:bg-blue-500 dark:hover:bg-blue-400">
Subscribe
</button>
</body>
Now add a toggle. The script should run before the first paint to avoid a flash. Place this inline in the <head>:
<script>
(function () {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = stored ? stored === 'dark' : prefersDark;
document.documentElement.classList.toggle('dark', isDark);
})();
</script>
This honors the user’s saved choice, falls back to the OS preference, and sets the class before any paint, so there is no flicker.
The toggle button itself:
<button id="theme-toggle" aria-label="Toggle dark mode">
Toggle
</button>
<script>
document.getElementById('theme-toggle').addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
</script>
For a more scalable approach, drive colors through CSS variables that flip with the class. This keeps the rest of your CSS theme-agnostic.
@layer base {
:root {
--bg: 255 255 255;
--fg: 15 23 42;
--card: 248 250 252;
}
.dark {
--bg: 15 23 42;
--fg: 241 245 249;
--card: 30 41 59;
}
}
Reference them in the Tailwind theme:
// tailwind.config.js
export default {
darkMode: 'class',
theme: {
extend: {
colors: {
bg: 'rgb(var(--bg) / <alpha-value>)',
fg: 'rgb(var(--fg) / <alpha-value>)',
card: 'rgb(var(--card) / <alpha-value>)',
},
},
},
};
Now your markup stays clean:
<body class="bg-bg text-fg">
<article class="bg-card rounded-lg p-4">Hello</article>
</body>
No dark: variants in templates. The variables swap on the class, and every utility built on them follows. Adding a third theme (a sepia mode, a high contrast mode) becomes one CSS block.
If you need three or more themes, swap from a binary class to a data attribute:
darkMode: ['class', '[data-theme="dark"]'],
Then set <html data-theme="sepia"> and define [data-theme="sepia"] { --bg: ...; }.
Common Pitfalls
Flash of unstyled or wrong theme (FOUC). It happens when your toggle script runs after the CSS paints. Always inline the theme bootstrap in the <head> before stylesheets render.
Forgetting to mirror every utility. With explicit dark: variants on every line, missing one shows up as a stripe of light gray in dark mode. The variable approach makes whole sections immune.
Storing theme as a JSON object or stringified boolean. Save the literal strings light or dark; debugging is easier and the value is self-explanatory.
Hard-coding hex colors in component CSS that does not use Tailwind tokens. Those will not swap with the theme. Convert them to variables or token classes.
Forgetting accessibility. Maintain at least 4.5:1 contrast in both themes for body text. Tools like the dev tools Contrast inspector check both modes.
Practical Tips
Add a “system” option in addition to “light” and “dark”. Persist the preference, but when set to “system” follow prefers-color-scheme live (listen for changes with matchMedia).
Group your color tokens by role (bg, surface, border, text, muted, accent) rather than by hue. The same component then works across themes without changes.
Test images and SVG. White logos disappear on white backgrounds. Provide a dark: swap or use currentColor in SVG and let the parent decide.
Use prefers-reduced-motion and prefers-contrast similarly; the same variable trick applies, and you respect more user preferences for free.
For Astro, Next.js, or any SSR framework, set the class on the server when you can read a cookie, and inline the bootstrap script as a fallback for users who have not chosen yet.
Wrap-up
Tailwind’s dark mode is one config flag plus one variant. The interesting decisions are who controls the theme, how it persists, and whether you scatter dark: variants or centralize the swap in CSS variables. Pick the class strategy, drive colors through variables, inline a bootstrap script, and your dark mode will feel like a built-in, not a bolt-on.
Related articles
- Tailwind Tailwind Arbitrary Values and the JIT Engine
How Tailwind's JIT engine generates classes on demand, when arbitrary values are the right tool, and how to keep your design system tidy.
- 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 Tailwind Design System Patterns That Scale
Build a design system on top of Tailwind that stays consistent as the app grows. Tokens, components, variants, and the cva pattern explained.
- Tailwind Tailwind vs Bootstrap: A Practical Comparison
A pragmatic comparison of Tailwind CSS and Bootstrap covering philosophy, bundle size, customization, and the right use cases for each framework.