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

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:
.darkno 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
| Dimensión | Class | Data-theme |
|---|---|---|
| Complejidad | Baja | Media |
| Claridad semántica | Media | Alta |
| Multitema | Difícil | Fácil |
| Comunidad | Alta | Media |
| Variables CSS | Adaptación extra | Nativo |
| Tailwind v3 | darkMode: 'class' | darkMode: ['selector', '...'] |
| Tailwind v4 | @custom-variant | @custom-variant |
| Librerías | Revisar compatibilidad | shadcn/ui, etc. |
| Especificidad | Clase | Atributo (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
- Proyecto simple: class + script mínimo
- Con shadcn/ui: data-theme + variables CSS
- Varios temas: data-theme obligatorio
- Next.js: next-themes con el
attributeque elijas - 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
- Tailwind CSS Dark Mode — documentación oficial
- shadcn/ui Theming
- next-themes en GitHub
- Astro Dark Mode with Tailwind
FAQ
¿En qué se diferencia @custom-variant de Tailwind v4 respecto a la configuración de v3?
¿Se pueden usar class y data-theme a la vez?
¿Qué hago si hay demasiados modificadores dark: y el código se vuelve verboso?
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?
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?
<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
Tailwind y shadcn/ui en práctica
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Diseño responsive con Tailwind: container queries y estrategia de breakpoints
Container queries y breakpoints en Tailwind CSS: de la ventana al contenedor, y cómo lograr layouts responsive a nivel de componente.
Parte 6 de 14
Siguiente
Patrones de composición en shadcn/ui: mejores prácticas para que varios componentes trabajen juntos
Aprende las mejores prácticas de los patrones de composición de shadcn/ui: Dialog+Form, DataTable+DropdownMenu y escenarios habituales, con el patrón Context, gestión de estado y optimización del rendimiento
Parte 8 de 14



Comentarios
Inicia sesión con GitHub para dejar un comentario