Dark Mode

Class strategy, theming, and avoiding dark-mode bugs.

On this page

How Dark Mode Works in Tailwind

Tailwind supports dark mode through the dark: variant. The idea is simple: you define base (light) styles, then override or extend them when dark mode is active.

Two strategies: media vs class

Tailwind can enable dark mode using system preference (media) or by toggling a class (class). In production, class is usually better because you can provide a user toggle and store the preference.

Config example

module.exports = {
  darkMode: "class",
  content: ["./public/**/*.php", "./assets/**/*.js"],
  theme: { extend: {} },
  plugins: [],
};

How class-based dark mode activates

When you use class mode, dark mode becomes active when a parent element (often html) has the class dark.

<html class="dark">
  ...
</html>

Basic dark mode pattern

<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
  ...
</div>

Think in roles, not random colors

The easiest way to keep dark mode consistent is to define roles: page background, surface, border, text, muted text, and primary. Then apply those roles everywhere.

Recommended role mapping

  • Light page background: slate-50
  • Dark page background: slate-950 or slate-900
  • Light surface: white
  • Dark surface: slate-900 or slate-800
  • Light border: slate-200
  • Dark border: slate-700
  • Light text: slate-900
  • Dark text: slate-100
  • Light muted text: slate-600
  • Dark muted text: slate-400

Production surface example

<div class="bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 min-h-screen">
  <div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg p-6">
    Card surface
  </div>
</div>

Borders and dividers in dark mode

Dark borders should be subtle but visible. Too dark borders look harsh; too light borders disappear. A common safe choice is slate-700 on dark surfaces.

Interactive states (hover/focus)

Hover and focus states must also work in dark mode. Do not rely on “slightly darker” backgrounds because dark surfaces have less visible contrast.

<a class="block rounded-lg p-4
           bg-white hover:bg-slate-50
           dark:bg-slate-900 dark:hover:bg-slate-800
           border border-slate-200 dark:border-slate-700">
  Hoverable row
</a>

Focus rings in dark mode

Rings remain important for accessibility. Use the same ring color but ensure enough contrast against dark surfaces. ring-offset is useful to separate the ring from the element background.

<button class="px-4 py-2 rounded-md bg-blue-600 text-white
               focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
               focus:ring-offset-white dark:focus:ring-offset-slate-900">
  Save
</button>

Avoid “flash of wrong theme”

In production, class-based dark mode can show a brief flash of light theme before your JavaScript adds the dark class. To avoid this, set the class as early as possible (inline script in the head) and persist preference (localStorage or cookie).

Common mistakes

  • Only switching backgrounds but not borders and muted text.
  • Using different neutral families (gray vs slate) inconsistently across dark mode.
  • Hover states that are invisible in dark mode.
  • Removing focus styles, making keyboard navigation impossible.
  • Relying on random hex colors instead of role-based tokens.

Production checklist

  • Use darkMode: "class" when you need a user toggle.
  • Define consistent roles (bg, surface, border, text, muted).
  • Ensure hover and focus states work in both themes.
  • Prevent theme flash by applying the class early.
  • Keep neutrals consistent (pick slate or gray and stick to it).