On this page

Dark Mode and Variants

12 min read TextCh. 3 — Visual styles

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

  1. Build a complete dark mode toggle button that saves the preference to localStorage and restores it on page load. Apply dark: variants to a card and navigation bar.
  2. Create a peer validation example: an email input where an error message below it becomes visible (peer-invalid:visible) when the input is invalid.
  3. Define a custom @variant hocus and apply it to navigation links for a combined hover + focus behavior.

class strategy vs media strategy
Tailwind v4 defaults to the class strategy for dark mode. The dark: variant activates when the closest ancestor element has the class dark. Use the media strategy (@media (prefers-color-scheme: dark)) if you want to respect the OS setting automatically without any JavaScript.
Custom variant with @variant in v4
Define your own pseudo-class variants in CSS: @variant hocus { &:hover, &:focus-visible { @slot; } }. This creates a hocus: prefix that applies styles on both hover and keyboard focus — useful for links and interactive elements.
Test color contrast in both modes
Every color pair (text on background) must meet WCAG AA contrast minimums in both light and dark modes. Use a tool like Tailwind's contrast checker or the WebAIM Contrast Checker at each shade combination. A color that works in light mode often fails in dark mode.
html
<!-- 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>