Cambiar tema

Modo oscuro en Tailwind: comparación entre class y data-theme

Easton editorial illustration: three-option fit selector

En pantalla parpadea esa línea dark:bg-gray-900 y surge la duda: ¿modo oscuro en Tailwind con class o con data-theme?

Probé las dos rutas durante un buen rato. Cada búsqueda en la documentación devolvía fragmentos sueltos que no encajaban del todo. Al final repasé la doc oficial, hilos en GitHub y el código de varias bibliotecas populares. Este artículo recoge los tropiezos y las decisiones que fui tomando.


Tres estrategias de modo oscuro en Tailwind

Primero aclaremos algo: Tailwind ofrece tres estrategias, no solo dos.

Estrategia media: sigue el sistema

Media es la opción por defecto — y mucha gente ni lo sabe. Usa la media query CSS prefers-color-scheme para detectar la preferencia del sistema.

<!-- Sin configuración: responde al sistema -->
<div class="bg-white dark:bg-gray-900">
  El contenido cambia según la configuración del sistema
</div>

Ventaja clara: cero configuración y una experiencia alineada con el SO. Inconveniente igual de claro: el usuario no puede elegir. Quien quiera oscuro en un entorno claro queda mal servido.

Estrategia class: control manual

Class añade la clase .dark en un ancestro (normalmente <html>) para activar el modo oscuro. Tú controlas el interruptor y la persistencia.

<!-- Control por JavaScript -->
<html class="dark">
  <body class="bg-white dark:bg-gray-900">
    Modo oscuro activo
  </body>
</html>

Es la más usada: mucha documentación y buena integración con librerías de terceros.

Estrategia data-theme: selector semántico

Data-theme usa data-theme="dark" en lugar de una clase. Es más explícito y escala bien a varios temas.

<html data-theme="dark">
  <body class="bg-white dark:bg-gray-900">
    Modo oscuro activo
  </body>
</html>

Ampliar a data-theme="oled" o data-theme="sepia" es trivial. Muy útil cuando necesitas más de dos apariencias.


Estrategia class en detalle

Cómo funciona

La idea es simple: si existe .dark en un ancestro, los estilos dark:* se aplican.

En Tailwind v3 se habilita en la configuración:

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

El CSS generado tiene esta forma:

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

En Tailwind v4, con enfoque CSS-first:

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

:where() baja la especificidad a cero y evita conflictos con otras reglas. Detalle importante.

Lógica de conmutación en JavaScript

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

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

// Inicializar
setTheme(getTheme());

Lee localStorage, cae al sistema si no hay preferencia y guarda al cambiar. Suficiente para la mayoría de casos.

Evitar el flash blanco

El flash al cargar ocurre porque el HTML se pinta en claro antes de que corra el JavaScript.

Solución: script síncrono en <head>:

<head>
  <script>
    // Síncrono para evitar parpadeo
    if (localStorage.theme === 'dark' ||
        (!('theme' in localStorage) &&
         window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  </script>
</head>

Sin defer ni async.

Ventajas e inconvenientes

Ventajas:

  • Implementación directa
  • Muchos recursos y recetas en la comunidad
  • Encaja bien con next-themes y similares
  • Especificidad algo mayor al usar clase

Inconvenientes:

  • .dark no es tan autoexplicativo
  • Varios temas implican varias clases
  • Con variables CSS hace falta algo de adaptación extra

Estrategia data-theme en detalle

Cómo funciona

Usa selectores de atributo en lugar de clases. En Tailwind v4:

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

CSS generado:

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

En v3, con array en la config:

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

Combinación con variables CSS

data-theme y variables CSS encajan muy bien:

/* 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%;  /* negro puro */
  --foreground: 0 0% 100%;
}

Referencia en Tailwind:

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

Al cambiar data-theme, todo lo que usa esas variables se actualiza sin dark: en cada componente.

Experiencia con shadcn/ui

shadcn/ui usa data-theme + variables CSS por defecto. En sus estilos verás:

@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%;
    /* ... más 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%;
    /* ... más variables */
  }
}

Soporta .dark y [data-theme='dark'] a la vez. Con shadcn/ui, cualquiera de las dos formas de activar el oscuro vale.

Extensión a varios temas

<html data-theme="oled">
  <!-- Fondo negro puro, ideal para OLED -->
</html>

<html data-theme="sepia">
  <!-- Fondo amarillento, lectura prolongada -->
</html>

Conmutación:

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

Con class, esto es más incómodo.

Ventajas e inconvenientes

Ventajas:

  • Semántica clara: data-theme="dark" se entiende al instante
  • Varios temas con un solo atributo
  • Integración natural con variables CSS
  • Compatible con shadcn/ui, daisyUI, etc.

Inconvenientes:

  • En v3 hay que configurar el selector a mano
  • Alguna librería puede requerir adaptación
  • Menos tutoriales que class (aunque mejora)

Matriz comparativa

Baja
Complejidad class
Configuración simple
Media
Complejidad data-theme
Requiere entender selectores de atributo
Alta
Soporte comunitario class
Documentación abundante
Media
Soporte comunitario data-theme
En adopción
Difícil
Multitema con class
Varias clases
Fácil
Multitema con data-theme
Cambiar el valor del atributo
Source: Análisis comparativo de estrategias
DimensiónClassData-theme
ComplejidadBajaMedia
Claridad semánticaMediaAlta
MultitemaDifícilFácil
ComunidadAltaMedia
Variables CSSAdaptación extraNativo
Tailwind v3darkMode: 'class'darkMode: ['selector', '...']
Tailwind v4@custom-variant@custom-variant
LibreríasRevisar compatibilidadshadcn/ui, etc.
EspecificidadClaseAtributo (similar)

¿Cuándo elegir class?

  • Solo claro/oscuro
  • Next.js + next-themes
  • Equipo acostumbrado a v3
  • Necesitas muchos ejemplos de la comunidad

¿Cuándo elegir data-theme?

  • Varios temas (OLED, sepia, etc.)
  • shadcn/ui u otra UI similar
  • Variables CSS como núcleo del diseño
  • Priorizas semántica en el markup

Integración con frameworks

Astro

La integración con Tailwind es sencilla; el punto delicado son View Transitions.

Configuración base:

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

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

Script de tema:

<!-- En el head de BaseLayout.astro -->
<script is:inline>
  const theme = localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');

  if (theme === 'dark') {
    document.documentElement.classList.add('dark');
    // o con data-theme:
    // document.documentElement.dataset.theme = 'dark';
  }
</script>

View Transitions:

Al cambiar de página, el DOM se regenera y puedes perder el tema. Escucha astro:after-swap:

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

Paso fácil de olvidar; yo también lo pasé por alto al principio.

Next.js + next-themes

En Next.js, next-themes encapsula persistencia, sistema e hidratación SSR.

Instalación:

npm install next-themes

Provider:

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

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"        // estrategia class
      defaultTheme="system"
      enableSystem={true}
      disableTransitionOnChange
    >
      {children}
    </ThemeProvider>
  );
}

Para data-theme, cambia attribute:

<ThemeProvider attribute="data-theme" defaultTheme="system">

Layout:

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

export default function RootLayout({ children }) {
  return (
    <html lang="es">
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Botón de conmutación:

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

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

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

next-themes se encarga del resto.


Novedades de Tailwind v4

Directiva @custom-variant

Antes en JS; ahora en CSS:

@import 'tailwindcss';

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

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

Más directo: no hace falta recompilar configuración JS para cambiar la variante.

@theme para 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);
}

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

Uso:

<button class="bg-primary text-white">Botón</button>

Sin dark:bg-primary-dark redundante.

Tres estados: light / dark / system

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;
  }
}

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

El usuario puede fijar un tema o seguir al sistema.


Mejores prácticas

Cómo elegir

  1. Proyecto simple: class + script mínimo
  2. Con shadcn/ui: data-theme + variables CSS
  3. Varios temas: data-theme obligatorio
  4. Next.js: next-themes con el attribute que elijas
  5. Astro: no olvides View Transitions

Evitar el flash

<head>
  <script is:inline>
    (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');
        // o
        document.documentElement.dataset.theme = 'dark';
      }
    })();
  </script>
</head>

SSR e hidratación

Con Next.js, evita mismatch de hidratación. next-themes ya lo resuelve; si lo haces a mano:

import { useEffect, useState } from 'react';

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

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

  return theme;
}

Nombres semánticos en variables

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

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

Facilita nuevos temas más adelante.


Resumen

En una frase: class es simple y maduro para la mayoría; data-theme es más claro y mejor para multitema y variables CSS.

Tailwind v4 unifica la configuración con @custom-variant. Si usas shadcn/ui, data-theme encaja de forma natural; para un oscuro básico, class sigue siendo sólido.

No subestimes los detalles de integración: View Transitions en Astro e hidratación en Next.js marcan la diferencia en la experiencia real.


Referencias

FAQ

¿En qué se diferencia @custom-variant de Tailwind v4 respecto a la configuración de v3?
La diferencia principal está en dónde se configura. En v3 se define en tailwind.config.js; en v4 se declara en el CSS con @custom-variant. La funcionalidad es la misma; v4 encaja mejor con el diseño CSS-first.
¿Se pueden usar class y data-theme a la vez?
Sí, pero no tiene mucho sentido. Hacen lo mismo y mezclarlos solo añade complejidad. shadcn/ui soporta .dark y [data-theme="dark"] para compatibilidad con distintos hábitos; tú puedes elegir una sola vía.
¿Qué hago si hay demasiados modificadores dark: y el código se vuelve verboso?
Usa variables CSS. Al cambiar el valor del atributo, todos los estilos que dependen de variables se actualizan sin dark: en cada elemento.

Pasos:
1. Define variables en globals.css con @theme
2. Sobrescribe valores bajo distintos [data-theme]
3. Referencia esas variables en tailwind.config.js

Así bg-primary se adapta al cambio de tema automáticamente.
¿Cómo evito que se pierda el estado del modo oscuro en un proyecto Astro?
View Transitions de Astro vuelve a renderizar el DOM al cambiar de página y puede resetear el tema. Escucha astro:after-swap y vuelve a aplicarlo:

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

Muchos desarrolladores se olvidan de este paso.
¿Cómo elimino el parpadeo blanco al cargar la página?
Coloca un script síncrono en <head> que aplique el tema antes del render:

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

El script debe ser síncrono: no uses defer ni async.

8 min de lectura · Publicado el: 28 mar 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog