On this page
Dark Mode and Variants
Dark Mode and Variants
Dark mode is one of the most requested features in modern web applications. Tailwind makes implementing dark mode straightforward with the dark: variant and a flexible configuration strategy. Understanding variants more broadly also unlocks powerful patterns for hover, focus, responsive, and custom states.
How the dark: variant works
The dark: prefix conditionally applies a utility when dark mode is active. Dark mode can be activated in two ways.
Class strategy (default in v4)
Add the dark class to the root <html> element. When present, all dark: utilities activate.
<!-- Light mode -->
<html>
<body class="bg-white text-gray-900">...</body>
</html>
<!-- Dark mode -->
<html class="dark">
<body class="bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100">...</body>
</html>Toggle dark mode with JavaScript:
// Toggle dark mode
document.documentElement.classList.toggle("dark");
// Set based on user preference
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", prefersDark);
// Save and restore from localStorage
const savedTheme = localStorage.getItem("theme");
if (savedTheme === "dark" || (!savedTheme && prefersDark)) {
document.documentElement.classList.add("dark");
}Media strategy
To respect the OS color scheme automatically without any JavaScript:
/* In your tailwind.css */
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));Or to use prefers-color-scheme media query as the trigger:
@import "tailwindcss";
/* Override the dark variant to use media query */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));Configure the dark mode strategy in v4 by overriding the dark variant inside your CSS file.
Dark mode patterns
Surface colors
<!-- Backgrounds -->
<body class="bg-white dark:bg-gray-950">
<div class="bg-gray-50 dark:bg-gray-900"> <!-- Elevated surface -->
<div class="bg-gray-100 dark:bg-gray-800"> <!-- Input background -->
<div class="bg-white dark:bg-gray-850"> <!-- Card surface -->Text colors
<h1 class="text-gray-900 dark:text-white">Primary heading</h1>
<p class="text-gray-700 dark:text-gray-300">Body text</p>
<p class="text-gray-500 dark:text-gray-400">Muted / secondary text</p>
<a class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-200">
Link that works in both modes
</a>Borders and dividers
<div class="border border-gray-200 dark:border-gray-700">...</div>
<hr class="border-gray-100 dark:border-gray-800" />
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<div class="py-4">Row 1</div>
<div class="py-4">Row 2</div>
</div>Icons and illustrations
<!-- Stroke icons -->
<svg class="text-gray-400 dark:text-gray-500" ...>
<!-- Fill icons -->
<svg class="fill-gray-600 dark:fill-gray-300" ...>
<!-- Invert illustrations for dark mode -->
<img class="invert dark:invert-0" src="/diagram-light.svg" alt="Diagram" />Complete dark mode color pattern
A well-designed dark mode needs more than just inverting colors. Here is a complete semantic color pattern:
<!-- Complete semantic layout with dark mode -->
<div class="min-h-screen bg-[#f9fafb] dark:bg-[#0a0a0f] text-gray-900 dark:text-gray-100">
<!-- Navbar: white in light, near-black in dark -->
<nav class="sticky top-0 z-50 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md border-b border-gray-200/50 dark:border-gray-800/50">
...
</nav>
<!-- Card: white in light, slightly elevated in dark -->
<div class="bg-white dark:bg-gray-900 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800 rounded-2xl p-6">
...
</div>
<!-- Input: light gray bg in light, darker in dark -->
<input
class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 rounded-lg px-3 py-2 w-full"
placeholder="Enter a value"
/>
</div>Understanding all variants
Dark mode is one of many variants Tailwind provides. Here is a broader overview.
Pseudo-class variants
<!-- State variants -->
<button class="hover:bg-indigo-700">Hover</button>
<input class="focus:ring-2 focus:ring-indigo-500" />
<button class="active:scale-95">Active</button>
<input class="disabled:opacity-50 disabled:cursor-not-allowed" />
<input class="checked:bg-indigo-600" type="checkbox" />
<input class="focus-visible:ring-2 focus-visible:ring-indigo-500" />
<a class="visited:text-purple-600">Visited link</a>
<!-- Structural variants -->
<li class="first:mt-0 mt-4">First child has no top margin</li>
<li class="last:mb-0 mb-4">Last child has no bottom margin</li>
<li class="odd:bg-gray-50">Odd rows</li>
<li class="even:bg-white">Even rows</li>
<p class="empty:hidden">Hidden when empty</p>Group and peer variants
<!-- group: child responds to parent state -->
<a href="/card" class="group block bg-white rounded-xl p-6 border hover:border-indigo-300 transition-colors">
<h3 class="font-bold text-gray-900 group-hover:text-indigo-700 transition-colors">Card title</h3>
<p class="text-gray-500 group-hover:text-gray-700 transition-colors">Supporting text</p>
<span class="mt-4 text-indigo-600 font-medium opacity-0 group-hover:opacity-100 transition-opacity">
Read more →
</span>
</a>
<!-- peer: sibling responds to another element's state -->
<div class="flex flex-col gap-1">
<input
id="email"
type="email"
class="peer border rounded-lg px-3 py-2 focus:ring-2 focus:ring-indigo-500 invalid:border-red-400"
placeholder="[email protected]"
required
/>
<p class="text-xs text-red-500 opacity-0 peer-invalid:opacity-100 transition-opacity">
Please enter a valid email address.
</p>
</div>Custom variants with @variant (v4)
In Tailwind v4, you can define custom variants directly in your CSS:
@import "tailwindcss";
/* Custom variant: applies on both hover and keyboard focus */
@variant hocus {
&:hover,
&:focus-visible {
@slot;
}
}
/* Custom variant: applies only when the .high-contrast class is on a parent */
@variant high-contrast (&:where(.high-contrast *));
/* Custom data attribute variant */
@variant loading (&[data-loading]);After this, hocus:bg-indigo-700, high-contrast:border-2, and loading:opacity-50 all work as expected.
Data attribute variants
Tailwind v4 supports data-* attribute variants:
<!-- Activate based on data attribute -->
<div data-active="true" class="data-[active=true]:bg-indigo-100 data-[active=true]:text-indigo-900 rounded-lg p-4">
Active state
</div>
<!-- ARIA state variants -->
<button aria-pressed="true" class="aria-pressed:bg-indigo-700 aria-pressed:text-white bg-white border px-4 py-2 rounded">
Toggle
</button>Practice
- Build a complete dark mode toggle button that saves the preference to
localStorageand restores it on page load. Applydark:variants to a card and navigation bar. - Create a peer validation example: an email input where an error message below it becomes visible (
peer-invalid:visible) when the input is invalid. - Define a custom
@variant hocusand apply it to navigation links for a combined hover + focus behavior.
<!-- Toggle dark mode by adding/removing 'dark' class on <html> -->
<!-- class strategy: <html class="dark"> enables dark mode -->
<div class="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors duration-300">
<!-- Navigation -->
<nav class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-6 py-4">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<span class="text-xl font-bold text-gray-900 dark:text-white">MyApp</span>
<!-- Theme toggle button -->
<button
type="button"
class="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle dark mode"
>
<!-- Sun icon (shown in dark mode) -->
<svg class="hidden dark:block w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<!-- Moon icon (shown in light mode) -->
<svg class="block dark:hidden w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</button>
</div>
</nav>
<!-- Content card -->
<main class="max-w-4xl mx-auto px-4 sm:px-6 py-12">
<div class="bg-white dark:bg-gray-900 rounded-2xl shadow-sm dark:shadow-gray-900/50 border border-gray-100 dark:border-gray-800 p-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Your content adapts beautifully between light and dark.
</p>
<div class="mt-6 grid grid-cols-3 gap-4">
<div class="bg-indigo-50 dark:bg-indigo-950/50 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-indigo-600 dark:text-indigo-400">128</p>
<p class="text-xs text-indigo-500 dark:text-indigo-400 mt-1">Lessons</p>
</div>
<div class="bg-emerald-50 dark:bg-emerald-950/50 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-emerald-600 dark:text-emerald-400">42%</p>
<p class="text-xs text-emerald-500 dark:text-emerald-400 mt-1">Complete</p>
</div>
<div class="bg-amber-50 dark:bg-amber-950/50 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400">7</p>
<p class="text-xs text-amber-500 dark:text-amber-400 mt-1">Badges</p>
</div>
</div>
</div>
</main>
</div>
Sign in to track your progress