Skip to content
C Codeloom
Tailwind

Tailwind Dark Mode: class Strategy Done Right

Set up dark mode in Tailwind with the class strategy, persist the choice in localStorage, and avoid the flash of wrong theme on first paint.

·5 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • Why the class strategy beats media queries
  • How to wire up a toggle that persists
  • How to avoid the flash of incorrect theme
  • How to design color tokens that work in both modes
  • How to test dark mode in component libraries

Prerequisites

Dark mode is no longer a nice-to-have. Users expect their site preference to be respected, their manual override to stick, and the page to render correctly on the very first paint. Tailwind makes the styling part trivial. The hard parts are wiring up the toggle, persisting the choice, and preventing the white flash before your script runs.

Two strategies, one winner

Tailwind supports two dark mode strategies:

  • media — uses the prefers-color-scheme CSS media query. Zero JavaScript. Cannot be overridden by the user.
  • class (or selector) — toggles styles when a class like dark is present on the html element. Lets you implement a manual toggle.

Real apps need a manual toggle, so class is the answer. Configure it once.

// tailwind.config.ts
import type { Config } from 'tailwindcss';

export default {
  darkMode: 'class',
  content: ['./src/**/*.{astro,html,js,ts,jsx,tsx}'],
} satisfies Config;

With this set, dark: variants apply when an ancestor has the dark class.

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

The three states

A robust dark mode supports three user states, not two:

  • light — explicit choice
  • dark — explicit choice
  • system — follow the OS

Store the choice as one of those three strings. Compute the resolved theme at runtime.

type ThemeChoice = 'light' | 'dark' | 'system';

function resolvedTheme(choice: ThemeChoice): 'light' | 'dark' {
  if (choice === 'system') {
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  }
  return choice;
}

The toggle script

Apply the resolved theme by adding or removing the dark class on document.documentElement.

export function applyTheme(choice: ThemeChoice) {
  const resolved = resolvedTheme(choice);
  document.documentElement.classList.toggle('dark', resolved === 'dark');
  localStorage.setItem('theme', choice);
}

export function initToggle() {
  const saved = (localStorage.getItem('theme') as ThemeChoice) ?? 'system';
  applyTheme(saved);

  // Re-apply when the OS theme changes and the user picked system.
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
    const current = (localStorage.getItem('theme') as ThemeChoice) ?? 'system';
    if (current === 'system') applyTheme('system');
  });
}

A toggle button just calls applyTheme('dark'), applyTheme('light'), or applyTheme('system').

Avoiding the flash

If the toggle script runs after the initial render, dark mode users see a white flash. The fix is an inline script in the document head, before any stylesheet or component renders. It runs synchronously and sets the class before paint.

<head>
  <script>
    (function () {
      try {
        var c = localStorage.getItem('theme') || 'system';
        var dark = c === 'dark' || (c === 'system' &&
          window.matchMedia('(prefers-color-scheme: dark)').matches);
        if (dark) document.documentElement.classList.add('dark');
      } catch (e) {}
    })();
  </script>
</head>

This script must be inline. A separate file delays execution past the first paint, which is exactly the problem you are solving.

Designing tokens for both modes

The easiest mistake is to scatter dark: variants everywhere. The cleaner approach is to define a small set of semantic tokens via CSS variables and switch them once.

:root {
  --bg: 255 255 255;
  --fg: 15 23 42;
  --muted: 100 116 139;
  --border: 226 232 240;
}
.dark {
  --bg: 15 23 42;
  --fg: 241 245 249;
  --muted: 148 163 184;
  --border: 51 65 85;
}

Wire them to Tailwind by extending the theme:

theme: {
  extend: {
    colors: {
      bg: 'rgb(var(--bg) / <alpha-value>)',
      fg: 'rgb(var(--fg) / <alpha-value>)',
      muted: 'rgb(var(--muted) / <alpha-value>)',
      border: 'rgb(var(--border) / <alpha-value>)',
    },
  },
},

Now your components use bg-bg text-fg border-border and pick up the right colors automatically. Add dark: variants only when a component genuinely needs a different design in dark mode, not just a different shade.

Images and media

Photos often look harsh on dark backgrounds. Two small techniques help:

  • Wrap raw images in a container with dark:opacity-90 to soften.
  • Use SVGs with currentColor for icons so they recolor with the text.
<svg class="text-fg" fill="currentColor" viewBox="0 0 24 24">...</svg>

Testing both modes

In Storybook, add a toolbar globalType that toggles the dark class on the preview body. In Playwright, run each test twice with a fixture that sets the theme. The goal is to catch contrast issues before they hit production.

Common pitfalls

  • Forgetting to set colorScheme: dark on form controls. Add <meta name="color-scheme" content="light dark"> and Tailwind’s accent- utilities so native inputs (scrollbars, date pickers) match.
  • Using transition-colors on the body. The very first theme change triggers a one-second fade across the entire page. Add the transition only on small components.
  • Storing the resolved theme instead of the choice. If you save dark and the user later wants to follow system, you lose intent. Save the choice.

Wrap up

Tailwind’s class strategy gives you the styling primitives. The rest is plumbing: a tri-state choice, a tiny inline script that runs before paint, semantic CSS variables, and a habit of designing components in both modes from day one. Get those right and dark mode stops being a polish task and becomes the default way you build.