Tailwind CSS Handbook v4.3

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#

Loading preview…

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.

CSS
@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:

CSS
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));
HTML
<html class="dark">
  <body>
    <div class="bg-white dark:bg-black">…</div>
  </body>
</html>

Data attribute instead of a class#

CSS
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
HTML
<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".

app.css
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));
Inline in <head> — before any content renders
<script>
  document.documentElement.classList.toggle(
    "dark",
    localStorage.theme === "dark" ||
      (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
  );
</script>
Toggle handlers
// 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:

HTML
<meta name="color-scheme" content="light dark">

Common patterns#

Pair every colour deliberately#

LightDarkRole
bg-whitedark:bg-gray-900Page background
bg-gray-50dark:bg-gray-800Raised surface
text-gray-900dark:text-gray-100Body text
text-gray-500dark:text-gray-400Muted text
border-gray-200dark:border-gray-700Dividers
ring-gray-900/5dark:ring-white/10Subtle 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.

app.css
@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);
}
HTML
<!-- No dark: variants needed -->
<div class="bg-surface text-content">
  <p class="text-content-muted">Adapts automatically.</p>
</div>

Images and illustrations#

HTML
<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:

HTML
<img src="/photo.jpg" alt="" class="dark:brightness-90 dark:contrast-105">

A complete toggle button#

Loading preview…

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. #000 with #fff causes halation for many readers. gray-900 on gray-50 is 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-500 with dark:text-indigo-100.
  • Respect forced-colors. In Windows High Contrast mode the OS overrides your palette. Test with forced-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-200 glows.
  • 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.