Tailwind CSS Dark Mode Toggle Tutorial: Complete Guide with localStorage & System Detection

Dark mode has gone from a nice-to-have feature to a user expectation. If you're building with Tailwind CSS, adding a dark mode toggle is surprisingly straightforward — but there are important details that separate a polished implementation from one that flickers, breaks, or frustrates your users.

In this tutorial, you'll learn how to build a complete dark mode toggle with Tailwind CSS, including localStorage persistence, system preference detection, and smooth transitions — with zero flicker.

What Is Dark Mode in Tailwind CSS?

Tailwind CSS provides a built-in dark: variant that lets you apply styles conditionally when dark mode is active. Instead of writing separate stylesheets or complex CSS overrides, you simply prefix any utility class with dark: to define its dark mode appearance.

For example:

<div class="bg-white dark:bg-gray-900">
  <h1 class="text-gray-900 dark:text-white">Hello, dark mode!</h1>
  <p class="text-gray-600 dark:text-gray-300">
    This text adapts automatically.
  </p>
</div>

By default, Tailwind uses the prefers-color-scheme CSS media feature, which means it follows whatever your operating system is set to. But for most real-world projects, you want users to be able to toggle dark mode manually — and that's where things get interesting.

Why Dark Mode Matters

Before diving into code, let's understand why dark mode is worth implementing properly.

User experience: Studies show that a significant portion of users prefer dark mode, especially at night. Giving users control over their viewing experience shows respect for their preferences and keeps them on your site longer.

Accessibility: For users with light sensitivity, migraines, or certain visual impairments, dark mode isn't just a preference — it's a necessity. Proper dark mode implementation improves the accessibility of your site.

Battery life: On OLED and AMOLED displays, dark mode genuinely reduces power consumption because black pixels are literally turned off.

Modern expectations: Major platforms like GitHub, Twitter/X, YouTube, and VS Code all offer dark mode. Users expect it from any modern web application.

Getting Started: Two Strategies

Tailwind CSS offers two approaches to dark mode, and understanding the difference is critical before you write a single line of code.

Strategy 1: Media Strategy (Automatic)

The default behavior uses the prefers-color-scheme CSS media query. Your site automatically matches the user's operating system theme. No JavaScript required.

/* No configuration needed — this is the default in Tailwind v4 */
@import "tailwindcss";

When to use it: Simple content sites, blogs, or documentation where you don't need a manual toggle.

The downside: Users can't override it within your application. If their OS is set to light mode but they want your site in dark mode, they're stuck.

Strategy 2: Selector Strategy (Manual Toggle)

This is what most production applications use. You configure Tailwind to look for a .dark class on a parent element (usually <html>), then toggle that class with JavaScript.

Tailwind v4 setup (CSS-first configuration):

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

Tailwind v3 setup (config file):

// tailwind.config.js
module.exports = {
  darkMode: 'selector',
  // ... rest of config
};

Important: If you're on Tailwind v3.4.1+, use 'selector' instead of the older 'class' value. The selector strategy replaced the class strategy and handles CSS specificity better.

Building the Toggle Button

Now let's build the actual toggle. Here's a clean, accessible dark mode button:

<button
  id="theme-toggle"
  type="button"
  class="rounded-lg p-2.5 text-gray-500 hover:bg-gray-100
         dark:text-gray-400 dark:hover:bg-gray-700
         focus:outline-none focus:ring-2 focus:ring-gray-200
         dark:focus:ring-gray-600"
  aria-label="Toggle dark mode"
>
  <!-- Sun icon (shown in dark mode) -->
  <svg
    id="theme-toggle-light-icon"
    class="hidden h-5 w-5"
    fill="currentColor"
    viewBox="0 0 20 20"
  >
    <path
      fill-rule="evenodd"
      d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0
         11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l
         -.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010
         1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0
         011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0
         011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0
         106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414
         8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0
         011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
      clip-rule="evenodd"
    />
  </svg>
  <!-- Moon icon (shown in light mode) -->
  <svg
    id="theme-toggle-dark-icon"
    class="hidden h-5 w-5"
    fill="currentColor"
    viewBox="0 0 20 20"
  >
    <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0
            1010.586 10.586z" />
  </svg>
</button>

Adding JavaScript: The Toggle Logic

Here's where the magic happens. This script handles three things: reading the saved preference, toggling the theme, and updating the icon.

const themeToggleBtn = document.getElementById('theme-toggle');
const lightIcon = document.getElementById('theme-toggle-light-icon');
const darkIcon = document.getElementById('theme-toggle-dark-icon');

// Set the initial icon based on current theme
if (
  localStorage.theme === 'dark' ||
  (!('theme' in localStorage) &&
    window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
  lightIcon.classList.remove('hidden');
} else {
  darkIcon.classList.remove('hidden');
}

// Handle the toggle click
themeToggleBtn.addEventListener('click', () => {
  // Toggle icons
  lightIcon.classList.toggle('hidden');
  darkIcon.classList.toggle('hidden');

  // Toggle dark class and save preference
  if (document.documentElement.classList.contains('dark')) {
    document.documentElement.classList.remove('dark');
    localStorage.theme = 'light';
  } else {
    document.documentElement.classList.add('dark');
    localStorage.theme = 'dark';
  }
});

Persisting Theme with localStorage

The code above already saves to localStorage, but the critical piece is loading the preference before the page renders. This prevents the dreaded flash of wrong theme (FOUC).

Add this script in your <head> tag — not at the bottom of the body, not deferred, not async. It must run before any content paints:

<head>
  <!-- ... other head elements ... -->
  <script>
    if (
      localStorage.theme === 'dark' ||
      (!('theme' in localStorage) &&
        window.matchMedia('(prefers-color-scheme: dark)').matches)
    ) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  </script>
</head>

This inline script is intentionally render-blocking. It runs synchronously before the browser paints the page, ensuring users never see a flash of the wrong theme.

Deep Dive: System Preference Detection

A polished implementation gives users three options: Light, Dark, and System (follow OS preference). Here's how to build a three-way toggle:

function applyTheme(theme) {
  const root = document.documentElement;

  if (theme === 'dark') {
    root.classList.add('dark');
    localStorage.theme = 'dark';
  } else if (theme === 'light') {
    root.classList.remove('dark');
    localStorage.theme = 'light';
  } else {
    // System preference
    localStorage.removeItem('theme');
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      root.classList.add('dark');
    } else {
      root.classList.remove('dark');
    }
  }
}

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

The matchMedia listener is key. When a user selects "System" and then changes their OS theme, your site updates in real time without a page reload.

Adding Smooth Transitions

Without transitions, toggling dark mode feels jarring — colors snap instantly. Adding CSS transitions makes the experience feel polished:

/* Add to your global CSS */
html.transition-colors,
html.transition-colors *,
html.transition-colors *::before,
html.transition-colors *::after {
  transition: background-color 0.3s ease,
              color 0.3s ease,
              border-color 0.3s ease,
              box-shadow 0.3s ease !important;
}

Then trigger it in your toggle function:

function toggleWithTransition(theme) {
  document.documentElement.classList.add('transition-colors');
  applyTheme(theme);

  // Remove transition class after animation completes
  setTimeout(() => {
    document.documentElement.classList.remove('transition-colors');
  }, 300);
}

Why not just leave transitions on permanently? Because the initial page load would animate from default colors to your theme colors, creating a visible flash. Adding and removing the transition class ensures smooth toggling without affecting page load.

Custom Color Schemes with CSS Variables

For larger projects, hardcoding colors like bg-white dark:bg-gray-900 everywhere gets repetitive. CSS custom properties let you define theme tokens once:

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

@layer base {
  :root {
    --color-bg-primary: #ffffff;
    --color-bg-secondary: #f3f4f6;
    --color-text-primary: #111827;
    --color-text-secondary: #6b7280;
    --color-border: #e5e7eb;
  }

  .dark {
    --color-bg-primary: #111827;
    --color-bg-secondary: #1f2937;
    --color-text-primary: #f9fafb;
    --color-text-secondary: #9ca3af;
    --color-border: #374151;
  }
}

@theme {
  --color-bg-primary: var(--color-bg-primary);
  --color-bg-secondary: var(--color-bg-secondary);
  --color-text-primary: var(--color-text-primary);
  --color-text-secondary: var(--color-text-secondary);
  --color-border: var(--color-border);
}

Now you can use these throughout your markup without the dark: prefix:

<div class="bg-bg-primary text-text-primary border-border">
  <!-- Automatically adapts to dark mode -->
</div>

This approach scales beautifully and makes future theme changes a single-file edit.

Component Patterns for Dark Mode

Here are practical patterns you'll use repeatedly:

Card Component

<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm
            dark:border-gray-700 dark:bg-gray-800 dark:shadow-gray-900/20">
  <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
    Card Title
  </h3>
  <p class="mt-2 text-gray-600 dark:text-gray-300">
    Card description that adapts to both themes.
  </p>
</div>

Input Field

<input
  type="text"
  placeholder="Search..."
  class="w-full rounded-lg border border-gray-300 bg-white px-4 py-2
         text-gray-900 placeholder-gray-400
         focus:border-blue-500 focus:ring-2 focus:ring-blue-500
         dark:border-gray-600 dark:bg-gray-700 dark:text-white
         dark:placeholder-gray-400
         dark:focus:border-blue-400 dark:focus:ring-blue-400"
/>

Navigation Bar

<nav class="border-b border-gray-200 bg-white dark:border-gray-700
            dark:bg-gray-900">
  <div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
    <a href="/" class="text-xl font-bold text-gray-900 dark:text-white">
      MySite
    </a>
    <div class="flex items-center gap-6">
      <a href="/about" class="text-gray-600 hover:text-gray-900
                              dark:text-gray-300 dark:hover:text-white">
        About
      </a>
      <!-- Theme toggle button goes here -->
    </div>
  </div>
</nav>

Common Mistakes to Avoid

After helping developers debug countless dark mode implementations, here are the mistakes I see most often:

1. Placing the Theme Script in the Wrong Location

The number one cause of the "flash of light mode" bug. Your theme detection script must be an inline <script> in the <head>, before any stylesheets finish loading. Putting it at the bottom of <body>, using defer, or loading it as an external file all cause FOUC.

2. Using media Strategy When You Need a Toggle

If you configure darkMode: 'media' (or leave it as default) but then try to add a toggle button, it won't work. The media strategy only responds to the OS preference. You need the selector/class strategy for manual toggling.

3. Forgetting to Handle Images and SVGs

Dark backgrounds can make light-colored images look jarring. Use Tailwind's dark:invert or dark:brightness-90 utilities, or provide alternate image sources:

<img
  src="/logo-dark.svg"
  class="hidden dark:block"
  alt="Logo"
/>
<img
  src="/logo-light.svg"
  class="block dark:hidden"
  alt="Logo"
/>

4. Not Testing Both Modes During Development

It's easy to build a feature in light mode and forget to check dark mode. Use your browser DevTools to quickly toggle prefers-color-scheme — in Chrome, open DevTools, press Ctrl+Shift+P, and search for "Emulate CSS prefers-color-scheme."

5. Using Pure Black Backgrounds

bg-black (#000000) on dark mode is harsh on the eyes. Use bg-gray-900 or bg-gray-950 instead. The slight warmth reduces eye strain significantly, especially in low-light conditions.

6. CSS Specificity Conflicts with Third-Party Libraries

After Tailwind v3.4.1's switch from class to selector strategy, the specificity of dark mode styles decreased. If third-party component libraries override your dark styles, add !important with Tailwind's ! prefix:

<div class="dark:!bg-gray-800">
  <!-- Forces dark background even with conflicting styles -->
</div>

7. Not Listening for System Theme Changes

If you support a "System" option, you need the matchMedia change listener shown earlier. Without it, users who switch their OS theme while your site is open won't see the change until they reload.

Frequently Asked Questions

Does dark mode affect SEO?

Dark mode itself has no direct impact on SEO. However, implementing it properly (no layout shifts, no FOUC) contributes to better Core Web Vitals scores, which Google does consider as a ranking signal.

Should I default to dark or light mode?

Default to the user's system preference. If they haven't set one, light mode is the safer default since it's what most users expect. The inline <head> script shown earlier handles this correctly.

Can I use dark mode with Tailwind v4?

Yes. Tailwind v4 fully supports dark mode. The configuration moved from tailwind.config.js to CSS using @custom-variant. The dark: variant works the same way in your markup — only the setup changed.

How do I handle dark mode in emails?

Email clients have limited CSS support. Dark mode in emails requires a different approach using @media (prefers-color-scheme: dark) in inline styles. Tailwind's dark mode toggle won't work in email templates.

Is there a performance cost to dark mode?

Practically none. The dark: variant generates standard CSS rules that browsers handle natively. The only JavaScript needed is the small inline script for theme detection and the toggle handler. There's no runtime CSS processing.

Conclusion

Building a dark mode toggle with Tailwind CSS comes down to four essentials: choosing the right strategy (selector for toggles, media for automatic), preventing FOUC with an inline head script, persisting the preference in localStorage, and respecting the user's system preference as a fallback.

The implementation we've built in this tutorial gives you a production-ready dark mode with zero flicker, three-way toggling (light, dark, system), smooth transitions, and real-time system preference syncing. You can drop this into any Tailwind project — whether you're using vanilla HTML, Astro, Next.js, or any other framework.

Start with the basic toggle, then layer on CSS custom properties as your design system grows. Your users — especially the ones reading your site at 2 AM — will thank you.