Skip to content
Codeloom
Tailwind

Dark Mode Strategies in Tailwind CSS

Master dark mode in Tailwind CSS with class strategy, media strategy, custom toggles, CSS variable theming, and flash-free server-side approaches.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How the class and media dark mode strategies differ under the hood
  • How to build a flash-free theme toggle with localStorage
  • How to use CSS variables so you never write dark: on every element
  • How to support three or more themes with data attributes
  • How to handle images, SVGs, and accessibility in dark mode

Prerequisites

  • A working Tailwind CSS project (v3.4+)
  • Basic understanding of CSS custom properties

Dark mode is no longer a nice-to-have. Users expect it, operating systems default to it, and apps that ignore it feel broken at night. Tailwind CSS ships two strategies for dark mode, and a third hybrid approach using CSS variables gives you the most flexibility of all. This article walks through each one with real code you can drop into a project today.

The Two Built-In Strategies

Tailwind exposes a darkMode key in your config. It accepts two values: 'media' and 'class'.

darkMode: 'media'
-> dark:bg-slate-900 compiles to @media (prefers-color-scheme: dark) { ... }
-> User has NO control. OS decides.

darkMode: 'class'
-> dark:bg-slate-900 compiles to .dark .bg-slate-900 { ... }
-> You toggle the .dark class on <html>. User has full control.
Media vs Class strategy

Media Strategy

The simplest option. Zero JavaScript required.

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

Now any dark: utility follows the OS preference automatically:

<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
  <h1 class="text-2xl font-bold">Hello, world</h1>
  <p class="text-gray-600 dark:text-gray-400">
    This text adapts to your OS theme automatically.
  </p>
</div>

The upside is zero maintenance. The downside is the user cannot override it. If someone has their OS set to light mode but prefers your app in dark mode, they are stuck.

Class Strategy

This is what most production apps use.

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

The dark: variants now only activate when an ancestor element (typically <html>) has the dark class. You control when that class appears.

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

Building a Flash-Free Toggle

The biggest mistake developers make is loading the theme preference after the page paints. The user sees a white flash before dark mode kicks in. Fix this by inlining a tiny script in the <head>, before any stylesheets render.

<head>
  <script>
    (function () {
      const saved = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const dark = saved === 'dark' || (!saved && prefersDark);
      document.documentElement.classList.toggle('dark', dark);
    })();
  </script>
  <!-- stylesheets go here -->
</head>

This script runs synchronously before the first paint. It checks localStorage first, falls back to the OS preference, and sets the class immediately.

The toggle button:

<button id="theme-toggle" class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700" aria-label="Toggle dark mode">
  <span class="dark:hidden">🌙</span>
  <span class="hidden dark:inline">☀️</span>
</button>

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

For a three-way toggle (light, dark, system), store 'system' as a third value and listen for OS changes:

function applyTheme(preference) {
  if (preference === 'system') {
    const osDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.classList.toggle('dark', osDark);
  } else {
    document.documentElement.classList.toggle('dark', preference === 'dark');
  }
  localStorage.setItem('theme', preference);
}

// Listen for OS changes when set to "system"
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (localStorage.getItem('theme') === 'system') {
      document.documentElement.classList.toggle('dark', e.matches);
    }
  });

CSS Variable Approach: Write dark: Once

Sprinkling dark: on every utility gets tedious fast. A better approach defines semantic color tokens as CSS variables, then swaps them with a single class.

/* globals.css */
@layer base {
  :root {
    --color-bg: 255 255 255;
    --color-fg: 15 23 42;
    --color-surface: 248 250 252;
    --color-border: 226 232 240;
    --color-accent: 59 130 246;
  }

  .dark {
    --color-bg: 15 23 42;
    --color-fg: 241 245 249;
    --color-surface: 30 41 59;
    --color-border: 51 65 85;
    --color-accent: 96 165 250;
  }
}

Wire them into your Tailwind config:

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

Now your markup is theme-agnostic:

<body class="bg-bg text-fg">
  <div class="bg-surface border border-border rounded-lg p-6">
    <h2 class="text-xl font-semibold">Dashboard</h2>
    <p class="text-fg/70 mt-2">Your metrics for today.</p>
    <button class="bg-accent text-white px-4 py-2 rounded mt-4">
      View report
    </button>
  </div>
</body>

No dark: variants anywhere in the template. Toggle the .dark class and every color updates automatically.

Custom Selector with Data Attributes

For more than two themes, use a custom selector:

// tailwind.config.js
export default {
  darkMode: ['class', '[data-theme="dark"]'],
};

Now define as many themes as you need:

[data-theme="dark"] {
  --color-bg: 15 23 42;
  --color-fg: 241 245 249;
}

[data-theme="sepia"] {
  --color-bg: 253 246 227;
  --color-fg: 92 64 51;
}

[data-theme="high-contrast"] {
  --color-bg: 0 0 0;
  --color-fg: 255 255 255;
}

Set the attribute on <html>:

<html data-theme="sepia">

Handling Images and SVGs

White logos disappear on white backgrounds. Dark screenshots look washed out on dark backgrounds. Handle these cases:

<!-- Swap images per theme -->
<img src="/logo-dark.svg" class="hidden dark:block" alt="Logo" />
<img src="/logo-light.svg" class="block dark:hidden" alt="Logo" />

<!-- Invert a decorative image -->
<img src="/diagram.png" class="dark:invert dark:hue-rotate-180" alt="Architecture" />

<!-- Use currentColor in SVGs -->
<svg class="w-6 h-6 text-fg" fill="currentColor" viewBox="0 0 24 24">
  <path d="M12 2L2 22h20L12 2z" />
</svg>

Accessibility Checklist

Dark mode is an accessibility feature. Do it right:

  1. Contrast ratios — Maintain at least 4.5:1 for body text and 3:1 for large text in both modes. Test with browser DevTools.
  2. Focus indicatorsfocus:ring-2 focus:ring-accent should be visible on both light and dark backgrounds.
  3. Color is not the only indicator — If you use red for errors, also use an icon or text label. Red on dark gray can be hard to distinguish.
  4. Respect prefers-reduced-motion — If you animate the theme transition, wrap it in motion-safe:.
  5. Test with screen readers — The aria-label on your toggle button should say what it does, not what it looks like.

Server-Side Rendering Considerations

In SSR frameworks like Next.js or Astro, you can read a cookie on the server to set the class before HTML is sent to the browser. This eliminates the flash entirely without any inline script:

// Next.js middleware example
import { NextResponse } from 'next/server';

export function middleware(request) {
  const theme = request.cookies.get('theme')?.value || 'light';
  const response = NextResponse.next();
  // Set a header the layout can read
  response.headers.set('x-theme', theme);
  return response;
}

In your layout, apply the class based on the header or cookie value.

Wrap-Up

Use the class strategy for user-controlled themes. Use CSS variables to avoid scattering dark: variants across every component. Inline a bootstrap script in the <head> to prevent flash. For three or more themes, switch to data attributes. Test contrast in both modes, swap images that do not adapt, and treat dark mode as the accessibility feature it is.