Switch Language
Toggle Theme

Tailwind Dark Mode: class vs data-theme Strategy Comparison

At 3 AM, staring at that flickering dark:bg-gray-900 on my screen, I finally started seriously thinking about one question: Should I use class or data-theme for Tailwind’s dark mode?

Honestly, these two strategies had been bugging me for a while. Every time I searched the docs, I’d find fragmentary explanations that never quite fit together. So I ended up digging through the official docs, GitHub discussions, and even the source code of several popular component libraries. Finally, I got things straightened out. This article lays out all the pitfalls I stepped into and the decisions I figured out—let’s talk it through.


Tailwind’s Three Dark Mode Strategies

Let’s clear something up first: Tailwind offers three dark mode strategies by default, not just two.

Media Strategy: Auto-Follow System

Media strategy is Tailwind’s default setting—honestly, many people probably don’t even know it’s the default. It uses the prefers-color-scheme CSS media query to automatically detect the user’s system dark mode preference.

<!-- No config needed, auto-responds to system settings -->
&lt;div class="bg-white dark:bg-gray-900"&gt;
  Content switches automatically based on system settings
&lt;/div&gt;

The benefit is obvious: zero configuration, users get a display that matches their habits without having to do anything. But the downside is equally painful—you can’t let users choose for themselves. For those who want dark mode in a light environment, the experience falls short.

Class Strategy: Manual Control Toggle

Class strategy is simply adding a .dark class on a parent element (usually &lt;html&gt;) to trigger dark mode. Now developers have full control—users can manually toggle, and preference persistence becomes possible.

<!-- JavaScript controls the class name -->
&lt;html class="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    Dark mode active
  &lt;/body&gt;
&lt;/html&gt;

This is the most widely used approach right now. Community docs are plentiful, and integration with various third-party libraries goes smoothly.

Data-theme Strategy: Semantic Attribute Selector

Data-theme strategy uses a data-theme="dark" attribute instead of a class name. Semantically clearer, and naturally supports multi-theme expansion.

&lt;html data-theme="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    Dark mode active
  &lt;/body&gt;
&lt;/html&gt;

Extending to more themes is especially simple—data-theme="oled" or data-theme="sepia", you define whatever you need. This is really handy when you need to support multiple display modes.


Class Strategy Deep Dive

Implementation Principle

The core principle of class strategy is actually quite simple: when the .dark class exists on some ancestor element in the DOM tree, all dark:* modifier styles become active.

In Tailwind v3, enable it through config file:

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
}

The generated CSS selector structure looks like this:

.dark .dark:bg-gray-900 {
  background-color: #111827;
}

Tailwind v4 switched to a brand-new CSS-first configuration approach, using the @custom-variant directive:

/* global.css */
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));

Notice that :where() pseudo-class—it pushes specificity down to zero, won’t interfere with other style priority calculations. That detail is pretty crucial.

JavaScript Toggle Logic

Implementing user toggle, a small chunk of JavaScript is enough:

// Get current theme
function getTheme() {
  return localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}

// Set theme
function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.classList.toggle('dark', theme === 'dark');
}

// Initialize
setTheme(getTheme());

This code does three things: reads user preference from localStorage, follows system when no preference exists, and toggles theme while saving. Enough for most cases.

Preventing White Screen Flash

Brief white screen flash on page load—I stepped into this pit too. The reason is straightforward: before JavaScript executes, HTML has already rendered in the default light mode.

The solution is placing a synchronously-executing script in &lt;head&gt;, setting the theme before DOM renders:

&lt;head&gt;
  &lt;script&gt;
    // Execute synchronously, prevent flash
    if (localStorage.theme === 'dark' ||
        (!('theme' in localStorage) &&
         window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  &lt;/script&gt;
&lt;/head&gt;

This script must be synchronous—defer or async won’t work.

Pros and Cons

Pros:

  • Simple and intuitive implementation, quick to pick up
  • Abundant community resources, mature solutions for various frameworks
  • Works well with next-themes and similar tool libraries
  • Slightly higher specificity, style override guaranteed

Cons:

  • .dark class name semantics aren’t clear enough—you have to think about it to know it’s dark mode
  • Multi-theme expansion needs multiple class names, gets messy to manage
  • Requires extra adaptation when combining with CSS variable approach

Data-theme Strategy Deep Dive

Implementation Principle

Data-theme strategy’s core is using attribute selectors, not class selectors. In Tailwind v4, configure like this:

@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

Generated CSS selector:

[data-theme='dark'] .dark:bg-gray-900 {
  background-color: #111827;
}

Tailwind v3 also supports this, but needs array configuration:

// tailwind.config.js
module.exports = {
  darkMode: ['selector', '[data-theme="dark"]'],
}

Combining with CSS Variables

Honestly, data-theme strategy and CSS variable approach are a match made in heaven. You can define different variable values under different data-theme:

/* globals.css */
:root {
  --background: 0 0% 100%;
  --foreground: 222 84% 5%;
}

[data-theme='dark'] {
  --background: 222 84% 5%;
  --foreground: 210 40% 98%;
}

[data-theme='oled'] {
  --background: 0 0% 0%;  /* Pure black */
  --foreground: 0 0% 100%;
}

Then reference these variables in Tailwind config:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
      }
    }
  }
}

With this, switching the data-theme attribute automatically switches all styles using these variables—no need to write dark: modifier on every component. The experience is genuinely comfortable.

shadcn/ui Practice Experience

shadcn/ui component library defaults to the data-theme + CSS variables approach. Flip through its style files, you’ll see tons of definitions like this:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    /* ... more variables */
  }

  .dark,
  [data-theme='dark'] {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    /* ... more variables */
  }
}

What’s interesting is it simultaneously supports .dark class and [data-theme='dark'] attribute—to accommodate different users’ habits. If you use shadcn/ui, either way works for triggering dark mode.

Multi-Theme Expansion Capability

Data-theme approach’s biggest advantage is right here—multi-theme support. Defining OLED mode, eye-care mode is straightforward:

&lt;html data-theme="oled"&gt;
  <!-- Pure black background, suitable for OLED screens -->
&lt;/html&gt;

&lt;html data-theme="sepia"&gt;
  <!-- Light yellow background, suitable for reading -->
&lt;/html&gt;

Toggle logic just changes an attribute value:

function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.dataset.theme = theme;
}

This kind of flexibility, class strategy really can’t easily achieve.

Pros and Cons

Pros:

  • Clear semantics—data-theme="dark" is dark mode at a glance
  • Natural multi-theme expansion support
  • Integrates smoothly with CSS variable approach
  • shadcn/ui, daisyUI and similar libraries default compatible

Cons:

  • Tailwind v3 requires custom selector configuration
  • Some third-party libraries might need adaptation
  • Community docs relatively fewer—but this situation is improving

Two Strategies Comparison Matrix

I put together a comparison table, listing all key dimensions:

Low
Class Implementation
Simple config
Medium
Data-theme Implementation
Need attribute selector understanding
High
Class Community Support
Rich documentation
Medium
Data-theme Community Support
Growing adoption
Difficult
Class Multi-theme
Multiple class names needed
Easy
Data-theme Multi-theme
Just change attribute
数据来源: Strategy comparison analysis
Comparison DimensionClass StrategyData-theme Strategy
Implementation ComplexityLow, simple configMedium, need attribute selector understanding
Semantic ClarityMedium, .dark meaning needs thoughtHigh, data-theme intuitive
Multi-theme ExpansionDifficult, need multiple class namesEasy, just change attribute value
Community SupportHigh, rich documentationMedium, growing adoption
CSS Variable IntegrationRequires extra adaptationNaturally friendly
Tailwind v3darkMode: 'class'darkMode: ['selector', '...']
Tailwind v4@custom-variant@custom-variant
Third-party Library CompatibilityNeed to check compatibilityshadcn/ui etc. naturally compatible
SpecificitySlightly higher (class selector)Same (attribute selector)

When to Choose Class Strategy?

  • Simple project, only need light/dark modes
  • Using Next.js + next-themes combination
  • Team familiar with Tailwind v3 config
  • Need to reference lots of community examples

When to Choose Data-theme Strategy?

  • Need to support multiple themes (like OLED, eye-care)
  • Using shadcn/ui or similar component libraries
  • Want deep integration with CSS variable approach
  • Project has high semantic requirements

Framework Integration Practice

Astro Integration Approach

Astro and Tailwind integration itself is simple, but there’s a pitfall—View Transitions handling.

Basic Configuration:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  vite: {
    plugins: [tailwindcss()]
  }
});

Dark Mode Script:

&lt;!-- Place in BaseLayout.astro head --&gt;
&lt;script is:inline&gt;
  // Synchronous script to prevent flash
  const theme = localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');

  if (theme === 'dark') {
    document.documentElement.classList.add('dark');
    // Or use data-theme
    // document.documentElement.dataset.theme = 'dark';
  }
&lt;/script&gt;

View Transitions Handling:

Astro’s View Transitions re-renders DOM on page switches, dark mode state easily gets lost. Need to listen for astro:after-swap event to reset theme:

&lt;script&gt;
  document.addEventListener('astro:after-swap', () => {
    const theme = localStorage.getItem('theme');
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    }
  });
&lt;/script&gt;

This step is pretty crucial—many developers easily miss it, I stepped into this pit too.

Next.js + next-themes Integration

Next.js projects recommend using the next-themes library. It packages up all the theme toggle logic, SSR compatibility and hydration handling—no worries needed.

Installation:

npm install next-themes

Provider Configuration:

// components/ThemeProvider.tsx
import { ThemeProvider } from 'next-themes';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    &lt;ThemeProvider
      attribute="class"        // Use class strategy
      defaultTheme="system"    // Default follow system
      enableSystem={true}      // Enable system detection
      disableTransitionOnChange  // Prevent toggle flash
    &gt;
      {children}
    &lt;/ThemeProvider&gt;
  );
}

Want to switch to data-theme strategy? Just change the attribute prop:

&lt;ThemeProvider attribute="data-theme" defaultTheme="system"&gt;

Use in Layout:

// app/layout.tsx
import { ThemeProvider } from './components/ThemeProvider';

export default function RootLayout({ children }) {
  return (
    &lt;html lang="en"&gt;
      &lt;body&gt;
        &lt;ThemeProvider&gt;
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}

Toggle Button Component:

// components/ThemeToggle.tsx
import { useTheme } from 'next-themes';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-lg"
    &gt;
      {theme === 'dark' ? '☀️' : '🌙'}
    &lt;/button&gt;
  );
}

next-themes automatically handles localStorage persistence, system preference detection, and hydration issues. Peace of mind.


Tailwind v4 New Features

Tailwind v4 brought a brand-new CSS-first configuration approach, and dark mode configuration changed too.

@custom-variant Directive

Variants that used to be defined in JavaScript config files can now be declared directly in CSS:

@import 'tailwindcss';

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

/* Data-theme strategy */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

The benefit is more intuitive—changing config doesn’t require rebuilding JavaScript.

@theme Directive for Variable Definition

Combined with data-theme strategy, use the @theme directive to define theme variables:

@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

@theme {
  --color-primary: oklch(0.65 0.2 150);
  --color-muted: oklch(0.9 0.02 200);
}

/* Variable override in dark mode */
[data-theme='dark'] {
  --color-primary: oklch(0.7 0.15 180);
  --color-muted: oklch(0.3 0.02 200);
}

Then directly use these colors:

&lt;button class="bg-primary text-white"&gt;Button&lt;/button&gt;

After switching data-theme, colors automatically change—no need to write dark:bg-primary-dark kind of redundant styles.

Three-State Toggle Implementation

For light/dark/system three-state toggle, need to combine with window.matchMedia API:

function setTheme(theme) {
  if (theme === 'system') {
    localStorage.removeItem('theme');
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
  } else {
    localStorage.setItem('theme', theme);
    document.documentElement.dataset.theme = theme;
  }
}

// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (!localStorage.getItem('theme')) {
      document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
    }
  });

With this, users can choose a fixed theme, or always follow the system.


Best Practices Summary

For most projects, my advice is this:

  1. Simple projects: Use class strategy, pair with a simple toggle script—enough
  2. Using shadcn/ui: Go straight with data-theme + CSS variables approach
  3. Need multi-theme: Must use data-theme strategy
  4. Next.js projects: Use next-themes, choose attribute based on needs
  5. Astro projects: Definitely pay attention to View Transitions handling

Practical Tips

Complete Solution for Preventing White Screen Flash:

&lt;head&gt;
  &lt;script is:inline&gt;
    // Synchronous script, executes before render
    (function() {
      const theme = localStorage.getItem('theme');
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

      if (theme === 'dark' || (!theme && systemDark)) {
        document.documentElement.classList.add('dark');
        // Or
        document.documentElement.dataset.theme = 'dark';
      }
    })();
  &lt;/script&gt;
&lt;/head&gt;

How to Handle SSR Projects:

Next.js and similar SSR projects need to avoid hydration mismatch. next-themes already handles this issue. But if you want to implement it yourself, need to watch out:

// Use useEffect to avoid SSR mismatch
import { useEffect, useState } from 'react';

function useTheme() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    setTheme(saved || 'light');
  }, []);

  return theme;
}

Semantic Naming for CSS Variables:

Use semantic variable names, not color names:

/* Recommended */
:root {
  --background: ...;
  --foreground: ...;
  --primary: ...;
  --muted: ...;
}

/* Not recommended */
:root {
  --white: ...;
  --black: ...;
  --gray-900: ...;
}

Semantic naming makes theme switching more intuitive, and adding new themes later is convenient.


Summary

After all this talk, it boils down to one sentence: class strategy is simple and mature, suitable for most projects; data-theme strategy has clear semantics, better for multi-theme scenarios and deep CSS variable integration.

Tailwind v4’s @custom-variant directive made both strategies’ configuration clean and intuitive. Which to choose, key is looking at your needs—if using shadcn/ui, data-theme approach is more natural; if just needing simple dark mode toggle, class strategy is still the reliable choice.

Don’t overlook one detail: handle those pitfalls properly when integrating with frameworks, like Astro’s View Transitions and Next.js’s SSR hydration. If these details aren’t handled well, the experience suffers.


FAQ

What's the difference between Tailwind v4's @custom-variant and v3's configuration?
The main difference is configuration location. v3 defines in JS config file (tailwind.config.js), v4 declares in CSS file using @custom-variant directive. Functionally identical, v4's approach better fits the "CSS-first" design philosophy.
Can I use both class and data-theme simultaneously?
You can, but there's no need. Both are functionally identical, using both just adds complexity. shadcn/ui supports both .dark class and [data-theme="dark"] attribute to accommodate different users' habits—you can pick one.
Too many dark: modifiers make code verbose, what should I do?
Use CSS variable approach. After defining variables, just switch attribute value and all styles using variables automatically update—no need to write dark: modifier on every element.

Specific approach:
1. Define variables in globals.css using @theme
2. Override variable values under different [data-theme]
3. Reference these variables in tailwind.config.js

This way bg-primary automatically adapts to theme switching.
Astro project dark mode state lost, what to do?
Astro's View Transitions re-renders DOM on page switches, causing dark mode state loss. Solution is listening for astro:after-swap event to reset theme:

document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});

This step many developers easily miss.
How to fix white screen flash on page load?
Place a synchronously-executing script in &lt;head&gt; to set theme before DOM renders:

&lt;script&gt;
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
&lt;/script&gt;

Note: Script must be synchronous, defer or async won't work.

References

9 min read · Published on: Mar 28, 2026 · Modified on: Mar 28, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts