Dark Mode
Implement dark mode with the dark: variant — the default media strategy, the class strategy via @custom-variant, and a three-way light/dark/system toggle.
Every colour utility can be prefixed with dark: to apply only in dark mode. The question is what "dark mode" means for your app, and v4 changes how you answer it.
Overview#
<div class="mx-auto max-w-sm rounded-xl bg-white p-6 shadow-lg ring-1 ring-gray-900/5 dark:bg-gray-800 dark:ring-white/10">
<h3 class="text-base font-medium tracking-tight text-gray-900 dark:text-white">Writes upside-down</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
The Zero Gravity Pen can be used to write in any orientation, including upside-down. It even works in outer space.
</p>
</div>Toggle this page's theme with the control in the header — the example follows it.
Strategy 1 — Follow the OS Default#
Out of the box, dark: compiles to @media (prefers-color-scheme: dark). No configuration, no JavaScript.
@import "tailwindcss";
/* nothing else needed */This is the right default. Use it unless you specifically need an in-app switch.
Strategy 2 — A class you control#
To let users override the OS preference, redefine the dark variant so it keys off a class:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));<html class="dark">
<body>
<div class="bg-white dark:bg-black">…</div>
</body>
</html>Data attribute instead of a class#
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));<html data-theme="dark">…</html>Strategy 3 — Light, dark, and system#
Most production apps want three states: forced light, forced dark, and "whatever the OS says". Store the explicit choice and treat its absence as "system".
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));<script>
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
);
</script>// Whenever the user explicitly chooses light mode
localStorage.theme = "light";
// Whenever the user explicitly chooses dark mode
localStorage.theme = "dark";
// Whenever the user explicitly chooses to respect the OS preference
localStorage.removeItem("theme");Also tell the browser, so form controls and scrollbars match:
<meta name="color-scheme" content="light dark">Common patterns#
Pair every colour deliberately#
| Light | Dark | Role |
|---|---|---|
bg-white | dark:bg-gray-900 | Page background |
bg-gray-50 | dark:bg-gray-800 | Raised surface |
text-gray-900 | dark:text-gray-100 | Body text |
text-gray-500 | dark:text-gray-400 | Muted text |
border-gray-200 | dark:border-gray-700 | Dividers |
ring-gray-900/5 | dark:ring-white/10 | Subtle outlines |
Note that muted text goes lighter in dark mode (500 → 400), not darker. Contrast requirements do not flip.
Semantic tokens instead of dark: everywhere#
If you find yourself writing dark: on every element, invert the problem: define semantic colours once and let CSS resolve them.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: var(--color-white);
--color-surface-muted: var(--color-gray-50);
--color-content: var(--color-gray-900);
--color-content-muted: var(--color-gray-500);
}
.dark {
--color-surface: var(--color-gray-900);
--color-surface-muted: var(--color-gray-800);
--color-content: var(--color-gray-50);
--color-content-muted: var(--color-gray-400);
}<!-- No dark: variants needed -->
<div class="bg-surface text-content">
<p class="text-content-muted">Adapts automatically.</p>
</div>Images and illustrations#
<img src="/logo-light.svg" alt="Acme" class="dark:hidden">
<img src="/logo-dark.svg" alt="Acme" class="hidden dark:block">For photos, a light touch usually reads better than swapping assets:
<img src="/photo.jpg" alt="" class="dark:brightness-90 dark:contrast-105">A complete toggle button#
<div class="p-4">
<button type="button" onclick="document.documentElement.classList.toggle('dark')"
class="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
<svg class="size-4 dark:hidden" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path stroke-linecap="round" d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6l1.4 1.4m10 10 1.4 1.4m0-12.8-1.4 1.4m-10 10-1.4 1.4"/></svg>
<svg class="hidden size-4 dark:block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>
<span class="dark:hidden">Light</span>
<span class="hidden dark:inline">Dark</span>
</button>
</div>Accessibility#
- Contrast still applies. Dark mode does not relax WCAG. Body text needs 4.5:1 against its background; large text needs 3:1.
- Avoid pure black on pure white text.
#000with#fffcauses halation for many readers.gray-900ongray-50is easier on the eyes. - Desaturate accents. A colour tuned for a white background is often too intense on a dark one. Step down:
bg-indigo-600→dark:bg-indigo-500withdark:text-indigo-100. - Respect
forced-colors. In Windows High Contrast mode the OS overrides your palette. Test withforced-colors:and do not rely on colour alone to convey meaning.
Common mistakes#
- Only darkening backgrounds. Text, borders, shadows and rings all need attention. A dark card with
border-gray-200glows. - Shadows in dark mode. Shadows are nearly invisible on dark surfaces. Swap to a ring:
shadow-lg dark:shadow-none dark:ring-1 dark:ring-white/10. - Flash of wrong theme. The theme script must be inline and blocking in
<head>. - Using
darkMode: 'class'in v4. That option no longer exists — use@custom-variant. - Forgetting
<meta name="color-scheme">. Native form controls and scrollbars will stay light.
Related topics#
- Colors — the palette and opacity modifier
- Theme & Design Tokens — defining semantic colours
- Custom Variants — how
@custom-variantworks - States & Variants — stacking
dark:with other variants