Dark Mode
|
This section documents Tailwind CSS v4.x — the CSS-first configuration line, whose v4.0 release shipped in January 2025 — as published at the official Tailwind CSS documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Tailwind iterates quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
Tailwind styles dark mode through the dark: variant, which is on by default and driven by the operating
system. You opt into a manual toggle by redefining that variant with @custom-variant.
The dark: variant
Prefix any utility with dark: and it applies only when dark mode is active. Out of the box that means the
prefers-color-scheme: dark
media query — no configuration, no JavaScript.
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-50">
<h1 class="text-2xl font-bold">Adaptive card</h1>
<p class="text-gray-600 dark:text-gray-400">
This block flips automatically when the OS is set to dark mode.
</p>
<button class="bg-sky-600 text-white hover:bg-sky-700
dark:bg-sky-500 dark:hover:bg-sky-400">
Action
</button>
</div>
See Dark mode.
Switching to a manual (class) strategy
To let users choose a theme regardless of their OS setting, redefine the dark variant with
@custom-variant so it keys
off a .dark class on an ancestor instead of the media query.
@import "tailwindcss";
/* dark: now matches when a .dark class is on the element or any ancestor */
@custom-variant dark (&:where(.dark, .dark *));
Then add or remove that class on <html>:
const root = document.documentElement;
document.querySelector("#toggle").addEventListener("click", () => {
root.classList.toggle("dark");
});
Every existing dark: utility keeps working unchanged; only the trigger moved from the media query to the
class.
The data-attribute strategy
If you prefer an attribute over a class — for example to expose more than two themes, or to keep class lists
clean — point the variant at a data-theme attribute instead.
@import "tailwindcss";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
<!-- server-rendered, or set by the inline script below -->
<html data-theme="dark">
<!-- dark: utilities are active for the whole document -->
</html>
A three-state toggle: light / dark / system
A good toggle has three states: force light, force dark, or follow the OS ("system"). Persist the explicit
choices in
localStorage and resolve "system" at
runtime with
matchMedia.
// theme.js -- states: "light" | "dark" | "system"
const STORAGE_KEY = "theme";
const media = window.matchMedia("(prefers-color-scheme: dark)");
const resolve = (choice) =>
choice === "system" ? (media.matches ? "dark" : "light") : choice;
export function applyTheme(choice) {
document.documentElement.classList.toggle("dark", resolve(choice) === "dark");
if (choice === "system") localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, choice);
}
export const currentChoice = () => localStorage.getItem(STORAGE_KEY) ?? "system";
// keep "system" live when the OS setting changes
media.addEventListener("change", () => {
if (currentChoice() === "system") applyTheme("system");
});
// wire up a <select id="theme-picker"> with light / dark / system options
// (guarded, so this module is safe to import on pages without the picker)
const picker = document.querySelector("#theme-picker");
if (picker) {
picker.value = currentChoice();
picker.addEventListener("change", () => applyTheme(picker.value));
}
To avoid a flash of the wrong theme, resolve it before the first paint with a tiny synchronous script at the
top of <head>, ahead of any stylesheet. This version matches the class strategy above:
<head>
<script>
(function () {
var stored = localStorage.getItem("theme");
var dark = stored === "dark" ||
(!stored && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
})();
</script>
<link rel="stylesheet" href="/app.css" />
</head>
If you chose the data-attribute strategy instead, set data-theme rather than the class in that same
script:
<script>
(function () {
var stored = localStorage.getItem("theme");
var dark = stored === "dark" ||
(!stored && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
})();
</script>
applyTheme() in the module above must set the same thing (classList.toggle("dark", …) for the class
strategy, dataset.theme = … for the attribute strategy) so the pre-paint script and the runtime toggle
stay in agreement.
Choosing a strategy
For the underlying CSS mechanism behind all of this — color-scheme, prefers-color-scheme, custom
properties, and the light-dark() function — see Light & Dark Theming.
driven by prefers-color-scheme"] B -- "No, users choose" --> D{Where should the flag live?} D -- "class on html" --> E["@custom-variant dark to the .dark selector
toggle classList, persist in localStorage"] D -- "data attribute on html" --> F["@custom-variant dark to the data-theme selector
set data-theme, persist in localStorage"] E --> G["Add the inline head script to avoid the theme flash"] F --> G
See the Tailwind dark mode documentation for the full reference.