Skip to content
C Codeloom
Tailwind

Tailwind Dark Mode Strategies: Class, Media, and CSS Variables

Compare the class-based, media-based, and variable-driven approaches to dark mode in Tailwind, with code and the trade-offs of each.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How Tailwind dark mode works under the hood
  • Pros and cons of media vs class strategies
  • How to wire a user-controlled theme toggle
  • How to use CSS variables for tokens that swap
  • How to avoid the flash of wrong theme on load

Prerequisites

  • A project using Tailwind CSS

What and Why

Dark mode is no longer a novelty; users expect it. Tailwind ships first-class support, but the choice between strategies has real implications: who decides the theme, how it persists, whether the OS preference is honored, and how easy it is to extend to more themes later.

Pick the right strategy up front and you save a refactor; pick the wrong one and you fight your CSS every time you add a component.

Mental Model

Tailwind’s dark: variant is just a conditional selector. The darkMode setting in your config controls how that condition is expressed.

darkMode: 'media'   -> dark:* applies when @media (prefers-color-scheme: dark)
darkMode: 'class'   -> dark:* applies when an ancestor has the .dark class
darkMode: ['class', '[data-theme="dark"]']  -> custom selector
Two strategies, one variant

media follows the OS, period. class lets the user override the OS via a toggle. Most modern apps choose class because users want control.

Hands-on Example

Configure class strategy:

// tailwind.config.js
export default {
  darkMode: 'class',
  content: ['./src/**/*.{astro,tsx,html}'],
  theme: { extend: {} },
};

Use the dark: variant in markup:

<body class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
  <button class="bg-blue-600 text-white hover:bg-blue-700
                 dark:bg-blue-500 dark:hover:bg-blue-400">
    Subscribe
  </button>
</body>

Now add a toggle. The script should run before the first paint to avoid a flash. Place this inline in the <head>:

<script>
  (function () {
    const stored = localStorage.getItem('theme');
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const isDark = stored ? stored === 'dark' : prefersDark;
    document.documentElement.classList.toggle('dark', isDark);
  })();
</script>

This honors the user’s saved choice, falls back to the OS preference, and sets the class before any paint, so there is no flicker.

The toggle button itself:

<button id="theme-toggle" aria-label="Toggle dark mode">
  Toggle
</button>

<script>
  document.getElementById('theme-toggle').addEventListener('click', () => {
    const isDark = document.documentElement.classList.toggle('dark');
    localStorage.setItem('theme', isDark ? 'dark' : 'light');
  });
</script>

For a more scalable approach, drive colors through CSS variables that flip with the class. This keeps the rest of your CSS theme-agnostic.

@layer base {
  :root {
    --bg: 255 255 255;
    --fg: 15 23 42;
    --card: 248 250 252;
  }
  .dark {
    --bg: 15 23 42;
    --fg: 241 245 249;
    --card: 30 41 59;
  }
}

Reference them in the Tailwind theme:

// tailwind.config.js
export default {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        bg:   'rgb(var(--bg) / <alpha-value>)',
        fg:   'rgb(var(--fg) / <alpha-value>)',
        card: 'rgb(var(--card) / <alpha-value>)',
      },
    },
  },
};

Now your markup stays clean:

<body class="bg-bg text-fg">
  <article class="bg-card rounded-lg p-4">Hello</article>
</body>

No dark: variants in templates. The variables swap on the class, and every utility built on them follows. Adding a third theme (a sepia mode, a high contrast mode) becomes one CSS block.

If you need three or more themes, swap from a binary class to a data attribute:

darkMode: ['class', '[data-theme="dark"]'],

Then set <html data-theme="sepia"> and define [data-theme="sepia"] { --bg: ...; }.

Common Pitfalls

Flash of unstyled or wrong theme (FOUC). It happens when your toggle script runs after the CSS paints. Always inline the theme bootstrap in the <head> before stylesheets render.

Forgetting to mirror every utility. With explicit dark: variants on every line, missing one shows up as a stripe of light gray in dark mode. The variable approach makes whole sections immune.

Storing theme as a JSON object or stringified boolean. Save the literal strings light or dark; debugging is easier and the value is self-explanatory.

Hard-coding hex colors in component CSS that does not use Tailwind tokens. Those will not swap with the theme. Convert them to variables or token classes.

Forgetting accessibility. Maintain at least 4.5:1 contrast in both themes for body text. Tools like the dev tools Contrast inspector check both modes.

Practical Tips

Add a “system” option in addition to “light” and “dark”. Persist the preference, but when set to “system” follow prefers-color-scheme live (listen for changes with matchMedia).

Group your color tokens by role (bg, surface, border, text, muted, accent) rather than by hue. The same component then works across themes without changes.

Test images and SVG. White logos disappear on white backgrounds. Provide a dark: swap or use currentColor in SVG and let the parent decide.

Use prefers-reduced-motion and prefers-contrast similarly; the same variable trick applies, and you respect more user preferences for free.

For Astro, Next.js, or any SSR framework, set the class on the server when you can read a cookie, and inline the bootstrap script as a fallback for users who have not chosen yet.

Wrap-up

Tailwind’s dark mode is one config flag plus one variant. The interesting decisions are who controls the theme, how it persists, and whether you scatter dark: variants or centralize the swap in CSS variables. Pick the class strategy, drive colors through variables, inline a bootstrap script, and your dark mode will feel like a built-in, not a bolt-on.