Módulo 12: Modo oscuro y temas
Objetivos del módulo
- Entender qué exige un modo oscuro de verdad y por qué invertir los colores no sirve
- Usar la variante
dark:con la preferencia del sistema y saber qué CSS genera - Elegir colores oscuros con criterio: capas de superficie, contraste medido y texto que no deslumbra
- Combinar
dark:conhover:,md:ygroup-*, y evitar el fallo clásico delhoverclaro sobre fondo oscuro - Explicar qué hace
color-schemey usarscheme-*para que formularios y barras de desplazamiento también se oscurezcan - Activar el modo oscuro a mano con
@custom-variant darky conocer su equivalentedarkModeen v3 - Escribir un conmutador de tres estados con JavaScript y
localStorage: claro, oscuro y sistema - Evitar el parpadeo de la carga, sincronizar pestañas y reaccionar cuando cambia el sistema
- Sustituir decenas de clases
dark:por colores semánticos con variables - Saber por qué
@themefalla en una sección y@theme inlineno - Crear varios temas con
data-*, incluidos temas por zonas de la página - Adaptar imágenes, iconos, sombras y gráficos
- Diagnosticar un modo oscuro que no se activa o que se activa a medias
1. Preparación y punto de partida
Sigues con el proyecto Vite del módulo 01. Todos los ejemplos usan los datos de la tienda de EMPRESA y necesitas solo esto en app.css hasta la sección 5, donde lo cambiaremos:
@import "tailwindcss";
Un modo oscuro bien hecho responde a tres preguntas, y este módulo sigue ese orden:
- Qué colores se usan cuando la interfaz es oscura. Es un problema de diseño, y Tailwind solo te da la herramienta: la variante
dark:. - Quién decide que la interfaz es oscura: el sistema operativo, el usuario con un botón o ambos.
- Cómo se organiza el proyecto cuando crece y
dark:empieza a repetirse por todas partes.
Las variantes se explicaron en Variantes y estados, y dark: es una más: aplica una utilidad solo si se cumple una condición.
⚠️ Importante: Vite no detecta siempre los HTML creados con el servidor en marcha. Si una clase nueva no aparece, guarda
app.csssin cambiar nada para que Tailwind vuelva a escanear el proyecto.
2. Qué es (y qué no es) un modo oscuro
Un modo oscuro no es “la misma página con los colores al revés”. Es una segunda paleta pensada para funcionar con poca luz, en la que cada color tiene un papel: fondo, superficie, texto principal, texto secundario, borde y acento.
La tentación de ahorrarse el trabajo con un filtro es grande. invert da la vuelta a todos los colores de un golpe, y con hue-rotate-180 se recupera el tono. Compara el resultado:
<!-- Atajo tentador: no lo uses -->
<div class="bg-gray-100 invert">...</div>
<div class="bg-gray-100 invert hue-rotate-180">...</div>

Figura 1. invert también invierte las imágenes y los colores de marca. Con hue-rotate-180 se corrige a medias, pero el fondo queda en negro puro, la sombra brilla como un halo blanco y la imagen sigue alterada. La cuarta tarjeta se ha diseñado para oscuro.
Fíjate en lo que falla, porque cada punto es una regla de diseño que aplicaremos después:
- Las imágenes no se deben invertir. Una foto invertida es un negativo.
- Las sombras no funcionan al revés. Una sombra negra invertida se convierte en un resplandor blanco.
- Los colores no son simétricos. El índigo que se lee bien sobre blanco se lee mal sobre gris oscuro, como medirás en la sección 3.
- Blanco puro invertido da negro puro, el fondo más incómodo para leer largo rato.
3. La variante dark: y la preferencia del sistema
Los sistemas operativos permiten elegir apariencia clara u oscura, y el navegador se lo cuenta a CSS mediante la consulta de medios prefers-color-scheme. Por defecto, dark: se limita a eso:
<body class="bg-gray-100 p-8 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<article class="max-w-sm rounded-xl border border-gray-200 bg-white p-6 shadow-md
dark:border-gray-800 dark:bg-gray-900 dark:shadow-none">
<p class="text-sm font-medium text-indigo-600 dark:text-indigo-400">Portátiles</p>
<h2 class="mt-1 text-xl font-bold">Portátil UltraBook 14</h2>
<p class="mt-2 text-gray-600 dark:text-gray-400">Portátil ligero de 14 pulgadas, 16 GB RAM</p>
<div class="mt-4 flex items-center justify-between">
<span class="text-2xl font-bold">899 €</span>
<button class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white
hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600">
Añadir a la cesta
</button>
</div>
</article>
</body>
Esta es la hoja que genera dark:bg-black, por ejemplo:
@media (prefers-color-scheme: dark) {
.dark\:bg-black {
background-color: var(--color-black);
}
}

Figura 2. Sin JavaScript y sin configurar nada: el navegador elige la versión según la preferencia del sistema.
Observa dos detalles del HTML:
- El
h2y el precio no llevandark:. Su color se hereda debody, así que basta con definirlo una vez arriba. - La sombra desaparece en oscuro y la tarjeta se separa del fondo por su color y su borde. Lo verás en la sección 10.
💡 Consejo: Para probar el modo oscuro no cambies el ajuste de tu sistema. En las herramientas de desarrollo de Chrome, abre el panel Rendering y usa la opción Emulate CSS media feature prefers-color-scheme. Así alternas entre claro y oscuro sin salir de la página.
Por qué no conviene el negro puro
Una interfaz necesita distinguir niveles: el fondo de la página, una tarjeta encima, un desplegable encima de la tarjeta. En claro lo resuelve la sombra. En oscuro la sombra apenas se ve, y lo que comunica la altura es el color: cuanto más arriba está una capa, más clara es.
<!-- Todo negro: no hay niveles -->
<div class="bg-black text-white">
<article class="rounded-xl bg-black p-4">...</article>
</div>
<!-- Tres capas: gray-950, gray-900 y gray-800 -->
<div class="bg-gray-950 text-gray-100">
<article class="rounded-xl bg-gray-900 p-4">
<h2 class="font-bold">Pedido 5001</h2>
<p class="text-sm text-gray-400">Enviado el 17 de enero de 2025</p>
<div class="mt-3 rounded-lg bg-gray-800 p-3 text-sm">Portátil Gaming 16 × 1</div>
</article>
</div>

Figura 3. El contenido es idéntico. A la izquierda no se ve estructura; a la derecha se lee de un vistazo qué contiene a qué, y el texto secundario se distingue del principal.
Contraste medido, no a ojo
Las pautas WCAG piden una relación de contraste mínima de 4,5:1 para el texto normal. Estos valores se han calculado con los colores reales de Tailwind. Son de la 4.3.3; en 3.4 la diferencia es de unas pocas décimas porque la paleta cambió de valores, y solo una pareja cambia de veredicto: white sobre indigo-500 da 4,58:1 en 4.3.3 y 4,47:1 en 3.4, justo por debajo del mínimo.
| Texto | Fondo | Contraste | ¿Texto normal? |
|---|---|---|---|
gray-900 | white | 17,7:1 | Sí |
gray-600 | white | 7,6:1 | Sí |
gray-400 | white | 2,6:1 | No |
indigo-600 | white | 6,5:1 | Sí |
gray-100 | gray-950 | 18,3:1 | Sí |
gray-400 | gray-900 | 6,8:1 | Sí |
gray-500 | gray-900 | 3,7:1 | No |
gray-600 | gray-900 | 2,3:1 | No |
indigo-600 | gray-900 | 2,7:1 | No |
indigo-400 | gray-900 | 5,7:1 | Sí |
red-600 | gray-900 | 3,7:1 | No |
red-400 | gray-900 | 6,1:1 | Sí |
white | indigo-600 | 6,5:1 | Sí |
white | indigo-500 | 4,6:1 | Sí, por poco |
green-300 | green-950 | 10,7:1 | Sí |
green-800 | green-100 | 6,5:1 | Sí |
De la tabla sale una regla práctica que recordar: al pasar a oscuro, los tonos de texto suben unos 200 puntos en la escala. gray-600 pasa a gray-400; indigo-600 a indigo-400; red-600 a red-400. En los fondos de color pasa lo contrario: green-100 se convierte en green-950, y el texto de green-800 en green-300.
| Papel | Claro | Oscuro |
|---|---|---|
| Fondo de la página | bg-gray-100 | dark:bg-gray-950 |
| Superficie (tarjeta) | bg-white | dark:bg-gray-900 |
| Superficie elevada | bg-white con sombra | dark:bg-gray-800 |
| Texto principal | text-gray-900 | dark:text-gray-100 |
| Texto secundario | text-gray-600 | dark:text-gray-400 |
| Bordes | border-gray-200 | dark:border-gray-800 |
| Enlaces y acentos | text-indigo-600 | dark:text-indigo-400 |
| Etiqueta de estado | bg-green-100 text-green-800 | dark:bg-green-950 dark:text-green-300 |
📘 Concepto: Texto
gray-100en lugar de blanco puro sobregray-950no es falta de contraste: sigue dando 18,3:1. Se elige así porque el blanco total sobre fondo muy oscuro resulta demasiado intenso en párrafos largos.
dark: con otras variantes
dark: se apila como cualquier variante. Estas combinaciones funcionan y se usan a diario:
| Clase | Se aplica si… |
|---|---|
dark:hover:bg-gray-600 | Modo oscuro y puntero encima |
dark:focus-visible:outline-indigo-400 | Modo oscuro y foco de teclado |
dark:md:p-10 | Modo oscuro y ancho de al menos md |
dark:group-hover:text-indigo-300 | Modo oscuro y puntero sobre el group |
En este caso el orden de apilado no cambia nada: dark:hover: y hover:dark: hacen lo mismo. Por costumbre se escribe dark: primero, para encontrar la pareja de cada clase al leer.
El error del hover claro sobre fondo oscuro
Este es el fallo más frecuente del módulo. Mira estos dos botones:
<!-- Le falta dark:hover -->
<button class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:text-white">...</button>
<!-- Correcto -->
<button class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600">...</button>
Medido en un navegador en modo oscuro, con el puntero encima:
| Botón | En reposo | Con hover |
|---|---|---|
Sin dark:hover | gray-700 | gray-300, con el texto blanco casi invisible |
Con dark:hover | gray-700 | gray-600 |
Se podría pensar que dark:bg-gray-700 va después en la hoja y debería ganar. Pero el orden solo decide entre selectores de igual especificidad, y aquí no lo son:
.hover\:bg-gray-300:hover { ... } /* especificidad (0,2,0): clase + pseudoclase */
.dark\:bg-gray-700 { ... } /* especificidad (0,1,0): solo una clase */
La regla es sencilla: si un elemento tiene hover: de color, necesita su dark:hover:. Lo mismo vale para focus:, active: y disabled:.
not-dark: 🆕 v4.0
🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe la clase sin variante y anúlala con
dark:, por ejemplohover:bg-gray-300 dark:hover:bg-gray-600.
not-dark: aplica una clase solo en claro. Es la variante not-* del módulo 11 sobre dark:
<button class="not-dark:hover:bg-gray-300 dark:hover:bg-gray-600">...</button>
@media not (prefers-color-scheme: dark) {
.not-dark\:bg-white { background-color: var(--color-white); }
}
Lo normal es no necesitarla, porque una clase sin variante ya sirve de valor por defecto. Es útil cuando una utilidad no tiene un valor natural con el que anularla en oscuro.
4. color-scheme: lo que dark: no puede pintar
Tus clases controlan lo que tú pintas, pero una parte de la página la dibuja el navegador: barras de desplazamiento, el icono del calendario de <input type="date">, la flecha de <select>, casillas, deslizadores y la lista desplegable. Si no le avisas, los sigue dibujando en claro.
La propiedad CSS color-scheme es ese aviso. No cambia ningún color tuyo: le dice al navegador en qué esquema debe pintar sus propios controles, y qué colores de sistema usar.

Figura 4. Mismo fondo y mismo HTML. A la izquierda, el icono del calendario casi no se ve, el carril del deslizador deslumbra y la barra de desplazamiento es blanca. A la derecha, scheme-dark lo arregla todo con una sola clase.
Las clases scheme-* 🆕 v4.0
🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa propiedades arbitrarias:
[color-scheme:dark]ydark:[color-scheme:dark]generan el mismo CSS.
| Clase | CSS | Uso |
|---|---|---|
scheme-normal | color-scheme: normal | Valor por defecto |
scheme-light | color-scheme: light | Controles claros |
scheme-dark | color-scheme: dark | Controles oscuros |
scheme-light-dark | color-scheme: light dark | La página admite ambos y decide el sistema |
scheme-only-light | color-scheme: only light | Impide que el navegador oscurezca la página por su cuenta |
scheme-only-dark | color-scheme: only dark | Lo mismo con oscuro |
color-scheme se hereda, así que lo normal es ponerlo una vez en <html>:
<html lang="es" class="dark:scheme-dark">
Aunque también se puede aplicar a un solo elemento, y así se ha hecho la figura 4: una sección con scheme-dark junto a otra sin él.
La etiqueta <meta name="color-scheme">
CSS tarda un instante en descargarse. Mientras tanto, el navegador no sabe que tu página admite oscuro y usa un lienzo blanco. Esta etiqueta se lo dice desde el HTML, antes de que llegue la hoja de estilos:
<meta name="color-scheme" content="light dark">
Con ella, y el sistema en oscuro, el lienzo arranca oscuro desde el primer momento. Pero tiene una consecuencia que hay que conocer, y que veremos al añadir el botón: manda sobre color-scheme: normal.
La función light-dark()
CSS incluye una función que elige entre dos colores según el color-scheme calculado del elemento. Se puede usar con un valor arbitrario, sin dark::
<html class="scheme-light-dark">
<div class="bg-[light-dark(white,black)]">...</div>
</html>
Medido con varios div: con color-scheme: light da blanco, con dark da negro y con light dark sigue al sistema. Sin color-scheme ni etiqueta meta da siempre el primer valor, un olvido fácil de cometer. Es cómoda para un retoque suelto, pero dark: y las variables de la sección 8 son más legibles en un proyecto.
5. El modo oscuro bajo control: @custom-variant dark
Seguir al sistema tiene un límite: el usuario no puede elegir. Quien tiene el móvil en oscuro quizá quiera tu tienda en claro. Para eso hay que hacer que dark: dependa de algo que tú controles: una clase o un atributo en <html>.
Activar el modo manual 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) se configura en
tailwind.config.js, no en el CSS. Si usas la LTS: escribedarkMode: 'selector'para usar la clase.dark, odarkMode: ['selector', '[data-modo="oscuro"]']para un atributo. Genera el mismo selector:where()que v4.
En v4, añade una línea tras el @import. Así redefines la variante dark:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
El & representa el elemento que lleva la clase, y :where(.dark, .dark *) significa “este elemento tiene .dark, o la tiene algún antepasado”. Compara el CSS antes y después:
/* Sin @custom-variant: pregunta al sistema */
@media (prefers-color-scheme: dark) {
.dark\:bg-black { background-color: var(--color-black); }
}
/* Con @custom-variant: mira la clase */
.dark\:bg-black:where(.dark, .dark *) {
background-color: var(--color-black);
}
Desde ese momento, el sistema operativo deja de influir. En la prueba, con el sistema en oscuro y sin .dark en <html>, la página sale clara. Tus clases dark: no cambian: el HTML es el mismo.
Con la variante lista, un botón mínimo:
<!doctype html>
<html lang="es" class="scheme-light dark:scheme-dark">
<head>
<meta charset="utf-8">
<title>Tienda EMPRESA</title>
<link rel="stylesheet" href="/app.css">
</head>
<body class="bg-gray-100 p-8 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<button id="conmutar" type="button"
class="rounded-lg border border-gray-300 px-3 py-1.5 dark:border-gray-700">
Cambiar tema
</button>
<script>
const boton = document.querySelector('#conmutar');
boton.addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
});
</script>
</body>
</html>
document.documentElement es el <html>. No hace falta nada más para cambiar la apariencia, porque cada dark: depende de ese antepasado. Este botón tiene un defecto evidente: al recargar se olvida. Lo arreglaremos en la sección 6.
¿Por qué :where()?
:where() tiene especificidad cero. Gracias a eso, .dark\:bg-black:where(...) pesa (0,1,0), lo mismo que una utilidad normal, y el orden de Tailwind sigue decidiendo. Si escribieras tu variante con :is(), cada clase dark: pesaría (0,2,0) y ya no podrías anularla con otra utilidad.
Se nota en v3, que ofrece los dos modos. Medido en un botón con hover:bg-gray-300 dark:bg-gray-700 print:bg-red-200:
| Modo en v3 | Selector generado | Reposo | Con hover | Al imprimir |
|---|---|---|---|---|
media | @media (prefers-color-scheme: dark) | gray-700 | gray-300 | red-200 |
selector | :where(.dark, .dark *) | gray-700 | gray-300 | red-200 |
class (antiguo) | :is(.dark *) | gray-700 | gray-700 | gray-700 |
Con darkMode: 'class' el hover: y el print: claros pierden sin avisar. Si heredas un proyecto v3 con ese valor, cámbialo a 'selector'. Y fíjate en que :is(.dark *) no incluye al propio elemento: <html class="dark dark:bg-black"> no funcionaría.
Variantes y data-*
Puedes usar cualquier selector. Un atributo es buena idea si luego vas a tener más de dos temas:
@custom-variant dark (&:where([data-modo=oscuro], [data-modo=oscuro] *));
<html lang="es" data-modo="oscuro">
color-scheme con botón: el detalle de scheme-light 🆕 v4.0
🆕 Desde Tailwind 4.0. Las clases
scheme-*no existen en la LTS (Tailwind 3.4). Si usas la LTS: escribe[color-scheme:light] dark:[color-scheme:dark]en<html>. Compilado con 3.4.19 ydarkMode: 'selector', se comporta igual.
Ya has visto que el botón de arriba lleva scheme-light dark:scheme-dark en <html>. Parece redundante, pero no lo es si la página tiene la etiqueta <meta name="color-scheme" content="light dark">. Esto es lo medido con el sistema en oscuro y el usuario habiendo elegido claro, es decir, sin .dark:
Clases en <html> | color-scheme calculado | Lienzo del navegador | Texto de <select> |
|---|---|---|---|
dark:scheme-dark | normal (decide la etiqueta meta) | rgb(18, 18, 18), oscuro | blanco |
scheme-light dark:scheme-dark | light | rgb(255, 255, 255), claro | negro |
Con la primera opción, el usuario pide claro y los controles nativos salen oscuros, con texto blanco sobre tu fondo blanco. La regla: con conmutador, pon siempre los dos lados. scheme-light dark:scheme-dark.
@variant dark dentro de tu CSS 🆕 v4.0
🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4):
@variantqueda sin procesar y llega tal cual al navegador. Si usas la LTS: usa@apply dark:bg-black, que sí funciona, o escribe el selector o la consulta de medios a mano.
Si escribes CSS propio, no repitas el selector de tu variante. Así, si un día cambias la estrategia, no tendrás que tocar ese código:
.caja-legal {
background: white;
@variant dark {
background: black;
}
}
/* Resultado con @custom-variant dark (&:where(.dark, .dark *)) */
.caja-legal {
background: white;
&:where(.dark, .dark *) {
background: black;
}
}
6. Un conmutador de verdad: claro, oscuro y sistema
El botón de la sección anterior no recuerda nada ni respeta la preferencia del sistema. Una solución seria necesita tres estados, porque “claro” y “lo que diga mi sistema” son decisiones distintas:
| Estado | localStorage.tema | Comportamiento |
|---|---|---|
| Claro | 'claro' | Claro siempre |
| Oscuro | 'oscuro' | Oscuro siempre |
| Sistema | no existe | Sigue a prefers-color-scheme, también si cambia con la página abierta |
Que “sistema” sea la ausencia de valor tiene una ventaja: un usuario nuevo no tiene nada guardado y automáticamente sigue a su sistema.
Figura 5. La decisión que hay que tomar antes de pintar nada.
Paso 1: el script que evita el parpadeo
La pieza clave se escribe directamente en <head>, antes de la hoja de estilos, sin type="module" ni defer:
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f3f4f6">
<script>
(function () {
let guardado = null;
try {
guardado = localStorage.getItem('tema');
} catch {
// Sin acceso a localStorage: seguimos la preferencia del sistema
}
const sistemaOscuro = matchMedia('(prefers-color-scheme: dark)').matches;
const oscuro = guardado === 'oscuro' || (guardado !== 'claro' && sistemaOscuro);
document.documentElement.classList.toggle('dark', oscuro);
})();
</script>
<link rel="stylesheet" href="/app.css">
Un <script> normal en <head> detiene el análisis del HTML. Esa pausa, que suele ser un defecto, aquí es exactamente lo que buscamos: cuando el navegador llegue a pintar <body>, la clase ya estará puesta.
El try/catch no es decorativo. El navegador puede lanzar una excepción solo por acceder a localStorage si el usuario ha bloqueado el almacenamiento. Sin él, el script se detiene y la página se queda sin tema. Se ha probado simulando esa excepción: la página sigue al sistema y no hay ningún error.
⚠️ Importante: Si cargas este código desde
tema.jscontype="module"odefer, se ejecuta después de analizar el HTML. El navegador puede pintar antes de que llegue, y tendrás el parpadeo que vemos a continuación.
Esto es lo que ocurre con un usuario que eligió oscuro, si el tema se aplica desde un módulo que tarda en llegar. Para que se pueda capturar, el fichero se ha retrasado 900 ms de forma artificial. Con una conexión normal es un destello corto, pero sigue siendo visible, y en cada recarga:

Figura 6. Parpadeo de tema. A la izquierda, primer pintado: la página sale clara aunque el usuario eligió oscuro. A la derecha, el módulo ya se ha ejecutado.
Paso 2: los botones
Tres botones en grupo. aria-pressed comunica el estado a los lectores de pantalla y, además, Tailwind lo usa para el estilo con la variante aria-pressed: que viste en el módulo 11:
<div role="group" aria-label="Tema"
class="inline-flex rounded-lg border border-gray-300 p-0.5 dark:border-gray-700">
<button type="button" data-elegir-tema="claro"
class="rounded-md px-3 py-1 text-sm aria-pressed:bg-indigo-600 aria-pressed:text-white">Claro</button>
<button type="button" data-elegir-tema="oscuro"
class="rounded-md px-3 py-1 text-sm aria-pressed:bg-indigo-600 aria-pressed:text-white">Oscuro</button>
<button type="button" data-elegir-tema="sistema"
class="rounded-md px-3 py-1 text-sm aria-pressed:bg-indigo-600 aria-pressed:text-white">Sistema</button>
</div>
<script type="module" src="/tema.js"></script>
Los botones dicen “Claro”, “Oscuro” y “Sistema” con palabras. Un icono de sol o de luna solo sería ambiguo: ¿indica el estado actual o el que obtendrás al pulsar?
Paso 3: tema.js
const CLAVE = 'tema';
const consulta = matchMedia('(prefers-color-scheme: dark)');
const raiz = document.documentElement;
const metaColor = document.querySelector('meta[name="theme-color"]');
const botones = document.querySelectorAll('[data-elegir-tema]');
function leerTema() {
try {
return localStorage.getItem(CLAVE) ?? 'sistema';
} catch {
return 'sistema';
}
}
function guardarTema(tema) {
try {
if (tema === 'sistema') localStorage.removeItem(CLAVE);
else localStorage.setItem(CLAVE, tema);
} catch {
// Modo privado estricto o almacenamiento lleno: el tema dura hasta recargar
}
}
function aplicarTema(tema) {
const oscuro = tema === 'oscuro' || (tema === 'sistema' && consulta.matches);
raiz.classList.toggle('dark', oscuro);
metaColor?.setAttribute('content', oscuro ? '#030712' : '#f3f4f6');
botones.forEach((boton) => {
boton.setAttribute('aria-pressed', String(boton.dataset.elegirTema === tema));
});
}
botones.forEach((boton) => {
boton.addEventListener('click', () => {
const tema = boton.dataset.elegirTema;
guardarTema(tema);
aplicarTema(tema);
});
});
// El sistema cambia (por ejemplo, de noche) y el usuario no ha elegido nada
consulta.addEventListener('change', () => {
if (leerTema() === 'sistema') aplicarTema('sistema');
});
// Otra pestaña ha cambiado el tema. key vale null tras localStorage.clear()
window.addEventListener('storage', (evento) => {
if (evento.key === CLAVE || evento.key === null) aplicarTema(leerTema());
});
aplicarTema(leerTema());
Repasemos lo que no es obvio:
classList.toggle('dark', oscuro)con segundo argumento no alterna: pone la clase sioscuroes verdadero y la quita si es falso. Así el resultado no depende del estado anterior.consulta.addEventListener('change', ...)cubre el móvil que cambia a oscuro al anochecer con tu página abierta. Elifrespeta al usuario que eligió un tema fijo.- El evento
storagesolo se dispara en las demás pestañas, no en la que hizo el cambio. Por eso sincroniza sin bucles. theme-colores la etiqueta que algunos navegadores móviles usan para teñir su barra superior. Si no la actualizas, verás una barra clara encima de una página oscura.- La última línea deja los botones en su estado correcto al cargar. El script de
<head>ya había puesto la clase, pero aquél no conoce los botones, que aún no existen.

Figura 7. El conmutador con “Oscuro” pulsado. El estado activo se pinta con aria-pressed:bg-indigo-600.
Esta es la secuencia comprobada, con Playwright, sobre dos pestañas de la misma página:
| Acción | .dark | localStorage | Botón activo | theme-color |
|---|---|---|---|---|
| Carga inicial con sistema claro | no | nada | Sistema | #f3f4f6 |
| El sistema pasa a oscuro | sí | nada | Sistema | #030712 |
| El sistema vuelve a claro | no | nada | Sistema | #f3f4f6 |
| Clic en Oscuro | sí | oscuro | Oscuro | #030712 |
| (Mirando la otra pestaña) | sí | oscuro | Oscuro | #030712 |
| Recarga | sí | oscuro | Oscuro | #030712 |
| Clic en Sistema, con sistema claro | no | nada | Sistema | #f3f4f6 |
💡 Consejo: El código del
<head>y el detema.jscalculan lo mismo por duplicado, y es inevitable: uno debe ser en línea y bloqueante, el otro necesita los botones. Si cambias el nombre de la clavetema, cámbialo en los dos.
Alternativa: sistema por defecto, solo con CSS 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) esto se define en la configuración. Si usas la LTS: usa
darkMode: ['variant', ['@media (prefers-color-scheme: dark) { &:not(.claro *) }', '&:is(.oscuro *)']], que genera las dos reglas, aunque con especificidad extra también en.oscuro.
Hay otra forma de plantearlo. En lugar de que JavaScript calcule siempre si toca oscuro, deja que el CSS siga al sistema y usa las clases solo para anularlo. Esta variante usa la forma con cuerpo de @custom-variant, donde @slot marca el lugar en que irá la utilidad:
@custom-variant dark {
@media (prefers-color-scheme: dark) {
&:not(.claro, .claro *) {
@slot;
}
}
&:where(.oscuro, .oscuro *) {
@slot;
}
}
Y esto es lo medido, en las seis combinaciones:
| Sistema | Clase en <html> | Resultado de dark: |
|---|---|---|
| Claro | ninguna | Claro |
| Claro | .claro | Claro |
| Claro | .oscuro | Oscuro |
| Oscuro | ninguna | Oscuro |
| Oscuro | .claro | Claro |
| Oscuro | .oscuro | Oscuro |
Ventajas: si JavaScript falla o está desactivado, la página sigue al sistema, y ya no necesitas escuchar change. Inconveniente: el selector es más pesado, porque :not() sí suma especificidad, y las clases dark: dejan de pesar igual que las demás utilidades.
7. Comparar las estrategias
| Estrategia | Decide | JavaScript | Sin parpadeo | Cuándo usarla |
|---|---|---|---|---|
dark: por defecto | El sistema | No | Sí | Blogs, documentación, webs sin cuenta de usuario |
@custom-variant con clase | Tu código | Sí | Con script en <head> | Aplicaciones con preferencia del usuario |
| Sistema más anulación | Sistema y usuario | Solo para anular | Con script en <head> | Quieres que funcione también sin JavaScript |
Variables con data-* | Tu código | Sí | Con script en <head> | Más de dos temas (sección 9) |
Elige una al empezar, porque cambiar luego es sencillo: dark: en el HTML no se toca en ningún caso.
8. Colores semánticos: menos dark: 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) los colores se declaran en
tailwind.config.jsy no generan variables. Si usas la LTS: crea las variables tú mismo como canales RGB y regístralas comofondo: 'rgb(var(--fondo) / <alpha-value>)'entheme.extend.colors. El código completo está al final de esta sección.
Cuenta las clases dark: de la tarjeta de la sección 3: siete, para una sola tarjeta. En un proyecto de cincuenta componentes tendrás que acordarte de la pareja oscura de cada color, y un descuido deja un texto ilegible. Además, cambiar el gris del oscuro significa buscar y reemplazar por todo el proyecto.
La alternativa es poner nombre al papel de cada color, no a su aspecto: bg-superficie en lugar de bg-white dark:bg-gray-900. El HTML dice qué es cada cosa, y el valor concreto se decide en un solo sitio. A estos colores con nombre de papel se les llama tokens de diseño.
Colores que cambian con el tema
Sabes del módulo 02 que los valores de @theme son variables CSS reales en v4. Eso permite cambiarlas por zonas sin recompilar:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-fondo: var(--color-slate-100);
--color-superficie: var(--color-white);
--color-superficie-alta: var(--color-slate-50);
--color-linea: var(--color-slate-200);
--color-tinta: var(--color-slate-900);
--color-tinta-suave: var(--color-slate-600);
--color-acento: var(--color-indigo-600);
--color-sobre-acento: var(--color-white);
}
@layer base {
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
--color-fondo: var(--color-slate-950);
--color-superficie: var(--color-slate-900);
--color-superficie-alta: var(--color-slate-800);
--color-linea: var(--color-slate-700);
--color-tinta: var(--color-slate-100);
--color-tinta-suave: var(--color-slate-400);
--color-acento: var(--color-indigo-400);
--color-sobre-acento: var(--color-slate-950);
}
}
Cada --color-* de @theme crea su familia de utilidades: bg-superficie, text-tinta, border-linea, accent-acento. Ten en cuenta que los nombres se eligen para leerse bien tras el prefijo: --color-tinta da text-tinta, mientras que --color-texto daría el poco afortunado text-texto.
Con esto, la tarjeta deja de tener colores dark::
<body class="bg-fondo p-8 text-tinta">
<article class="max-w-sm rounded-xl border border-linea bg-superficie p-6 shadow-md dark:shadow-none">
<p class="text-sm font-medium text-acento">Portátiles</p>
<h2 class="mt-1 text-xl font-bold">Portátil UltraBook 14</h2>
<p class="mt-2 text-tinta-suave">Portátil ligero de 14 pulgadas, 16 GB RAM</p>
<div class="mt-4 flex items-center justify-between">
<span class="text-2xl font-bold">899 €</span>
<button class="rounded-lg bg-acento px-4 py-2 font-medium text-sobre-acento hover:bg-acento/85">
Añadir a la cesta
</button>
</div>
</article>
</body>
Figura 8. El HTML no sabe nada de temas. Cambiar de tema es cambiar el valor de una variable en un antepasado.
Sigue habiendo un dark:shadow-none, y es correcto: dark: no desaparece, se queda para lo que no es color. Y funcionan los modificadores de opacidad, porque bg-acento/15 se calcula en el navegador con color-mix() sobre la variable:
bg-acento/15 en claro: oklab(0.511 0.0318 -0.2601 / 0.15)
bg-acento/15 en oscuro: oklab(0.673 0.0220 -0.1807 / 0.15)
💡 Consejo: No hace falta eliminar todos los
dark:. Usa tokens para los papeles que se repiten (fondo, superficie, texto, borde) ydark:para casos puntuales, como las etiquetas de estado de colores de la sección 11.
⚠️ Importante: No redefinas la paleta original con trucos como
.dark { --color-gray-100: ...}para “invertirla”.text-gray-900pasaría a ser casi blanco en oscuro y nadie que lea el HTML entendería por qué.
La trampa de @theme con una variable propia 🆕 v4.0
🆕 Desde Tailwind 4.0.
@theme inlineno existe en la LTS (Tailwind 3.4). Si usas la LTS: no tienes esta trampa, porquergb(var(--fondo) / <alpha-value>)usa la variable directamente en la clase, y funciona igual en<html>que en una sección.
Muchas guías usan otro esquema: una variable propia, --fondo, cambia con el tema y @theme la referencia. Funciona si el tema se pone en <html>, pero falla si se pone en una sección:
@layer base {
:root { --fondo: #ffffff; --tinta: #0f172a; }
[data-tema=oscuro] { --fondo: #0f172a; --tinta: #f1f5f9; }
}
@theme {
--color-papel: var(--fondo);
--color-letra: var(--tinta);
}
@theme inline {
--color-papel-bien: var(--fondo);
--color-letra-bien: var(--tinta);
}
<section data-tema="oscuro" class="bg-papel text-letra">...</section>
<section data-tema="oscuro" class="bg-papel-bien text-letra-bien">...</section>

Figura 9. Las dos secciones tienen data-tema="oscuro", pero solo funciona la que usa @theme inline.
El CSS generado revela el motivo:
/* @theme: la utilidad apunta a una variable que se definió en :root */
:root, :host { --color-papel: var(--fondo); }
.bg-papel { background-color: var(--color-papel); }
/* @theme inline: la utilidad usa var(--fondo) directamente */
.bg-papel-bien { background-color: var(--fondo); }
Una variable que contiene var() se resuelve donde se declara. --color-papel se calcula en :root, con el --fondo de :root, y los hijos heredan ya el resultado: blanco. Redefinir --fondo en la sección llega tarde. En la prueba, con data-tema en <html> funcionan las dos, y por eso el fallo tarda en aparecer.
Hay dos soluciones válidas: usar @theme inline, o la de este módulo, que redefine directamente --color-* en cada tema y tiene un nivel de variables menos. Los detalles de @theme inline se ven en Personalizar el tema.
El equivalente completo en v3
// tailwind.config.js
export default {
darkMode: 'selector',
content: ['./*.html'],
theme: {
extend: {
colors: {
fondo: 'rgb(var(--fondo) / <alpha-value>)',
tinta: 'rgb(var(--tinta) / <alpha-value>)',
acento: 'rgb(var(--acento) / <alpha-value>)',
},
},
},
};
@tailwind base;
@tailwind utilities;
@layer base {
:root { --fondo: 241 245 249; --tinta: 15 23 42; --acento: 79 70 229; }
.dark { --fondo: 15 23 42; --tinta: 241 245 249; --acento: 129 140 248; }
}
Las variables llevan tres números sin rgb() para que Tailwind pueda añadir la opacidad. Compilado con Tailwind 3.4.19 y medido en el navegador: bg-acento/20 da rgba(79, 70, 229, 0.2) en claro y rgba(129, 140, 248, 0.2) con .dark, y una sección interior con .dark también cambia.
9. Temas múltiples con data-* 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) las variantes propias se crean con un plugin. Si usas la LTS: define los temas con las variables de canales de la sección 8; para
dark:usadarkMode: ['variant', '&:where([data-tema=oscuro], [data-tema=oscuro] *, [data-tema=oceano], [data-tema=oceano] *)'], y paratema-bosque:usaaddVariant('tema-bosque', '&:where([data-tema=bosque], [data-tema=bosque] *)')en un plugin.
Con los tokens ya hechos, un tema más es un bloque más. Modo oscuro y temas dejan de ser dos cosas distintas: “oscuro” es un tema como los demás.
Un atributo data-tema expresa mejor que varias clases que los temas se excluyen entre sí. Este ejemplo añade dos temas para campañas de la tienda:
@import "tailwindcss";
/* Los temas oscuros activan dark: */
@custom-variant dark (&:where([data-tema=oscuro], [data-tema=oscuro] *, [data-tema=oceano], [data-tema=oceano] *));
/* Una variante para retocar un tema concreto */
@custom-variant tema-bosque (&:where([data-tema=bosque], [data-tema=bosque] *));
@theme {
--color-fondo: var(--color-slate-100);
--color-superficie: var(--color-white);
--color-superficie-alta: var(--color-slate-50);
--color-linea: var(--color-slate-200);
--color-tinta: var(--color-slate-900);
--color-tinta-suave: var(--color-slate-600);
--color-acento: var(--color-indigo-600);
--color-sobre-acento: var(--color-white);
--radius-tarjeta: 0.75rem;
}
@layer base {
[data-tema=claro] {
color-scheme: light;
--color-fondo: var(--color-slate-100);
--color-superficie: var(--color-white);
--color-superficie-alta: var(--color-slate-50);
--color-linea: var(--color-slate-200);
--color-tinta: var(--color-slate-900);
--color-tinta-suave: var(--color-slate-600);
--color-acento: var(--color-indigo-600);
--color-sobre-acento: var(--color-white);
}
[data-tema=oscuro] {
color-scheme: dark;
--color-fondo: var(--color-slate-950);
--color-superficie: var(--color-slate-900);
--color-superficie-alta: var(--color-slate-800);
--color-linea: var(--color-slate-700);
--color-tinta: var(--color-slate-100);
--color-tinta-suave: var(--color-slate-400);
--color-acento: var(--color-indigo-400);
--color-sobre-acento: var(--color-slate-950);
}
[data-tema=oceano] {
color-scheme: dark;
--color-fondo: #06283d;
--color-superficie: #0b3a55;
--color-superficie-alta: #124e6f;
--color-linea: #1f6d94;
--color-tinta: #e6f6ff;
--color-tinta-suave: #a9d3e8;
--color-acento: #47c8d8;
--color-sobre-acento: #06283d;
--radius-tarjeta: 1.5rem;
}
[data-tema=bosque] {
color-scheme: light;
--color-fondo: #eef3e6;
--color-superficie: #fbfdf6;
--color-superficie-alta: #e3ecd5;
--color-linea: #c3d3ab;
--color-tinta: #22331a;
--color-tinta-suave: #4d6141;
--color-acento: #3f7a2a;
--color-sobre-acento: #ffffff;
--radius-tarjeta: 0.25rem;
}
}
Y el mismo HTML para los cuatro temas:
<div class="min-h-full bg-fondo p-6 text-tinta">
<header class="mb-4 flex items-center justify-between">
<strong class="text-lg">EMPRESA</strong>
<nav class="flex gap-3 text-sm text-tinta-suave">
<a href="#">Catálogo</a>
<a href="#">Pedidos</a>
</nav>
</header>
<article class="rounded-tarjeta border border-linea bg-superficie p-5 tema-bosque:border-2">
<p class="text-sm font-medium text-acento">Sobremesa</p>
<h2 class="mt-1 text-lg font-bold">Torre Workstation</h2>
<p class="mt-1 text-sm text-tinta-suave">Estación de trabajo para diseño y CAD</p>
<label class="mt-3 flex items-center gap-2 text-sm">
<input type="checkbox" checked class="accent-acento"> Con garantía ampliada
</label>
<button class="mt-3 rounded-lg bg-acento px-3 py-1.5 text-sm font-medium text-sobre-acento
dark:ring-1 dark:ring-white/20">
Añadir a la cesta
</button>
</article>
</div>

Figura 10. Mismo HTML, cuatro temas. Cada tema cambia colores, radio de las esquinas y color-scheme. La casilla usa accent-acento, así que también sigue al tema.
Observa varias decisiones:
- Un tema no es solo color.
--radius-tarjetacrearounded-tarjeta, y cada tema le da su valor. Lo mismo valdría para fuentes o espaciados. - Cada tema declara su
color-scheme. Océano es oscuro; bosque es claro. Si no, los controles nativos no acompañan. dark:lista todos los temas oscuros. Asídark:ring-1afecta a oscuro y a océano y nada más. Las medidas lo confirman: con bosque y claro no se aplica.tema-bosque:es para retoques. Aquí da un borde de 2 px solo en ese tema. Si necesitas muchos, es señal de que te falta un token.[data-tema=claro]repite los valores de@theme. Parece sobrar, pero hace falta para tener una zona clara dentro de una oscura, como verás enseguida. Si no vas a usar zonas, puedes quitar ese bloque.
Elegir el tema con JavaScript
En <head>, antes de la hoja de estilos. Comprueba que lo guardado sea un tema que todavía existe, por si un día retiras alguno:
<script>
(function () {
const temas = ['claro', 'oscuro', 'oceano', 'bosque'];
let tema = null;
try {
tema = localStorage.getItem('tema');
} catch {}
if (!temas.includes(tema)) {
tema = matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro';
}
document.documentElement.dataset.tema = tema;
})();
</script>
Y en <body>:
<label>Tema
<select id="tema" class="rounded-lg border border-linea bg-superficie px-2 py-1">
<option value="claro">Claro</option>
<option value="oscuro">Oscuro</option>
<option value="oceano">Océano</option>
<option value="bosque">Bosque</option>
</select>
</label>
<script>
const selector = document.querySelector('#tema');
selector.value = document.documentElement.dataset.tema;
selector.addEventListener('change', () => {
document.documentElement.dataset.tema = selector.value;
try {
localStorage.setItem('tema', selector.value);
} catch {
// Sin almacenamiento: el tema dura hasta recargar
}
});
</script>
dataset.tema lee y escribe el atributo data-tema. Probado: al cambiar el <select>, cada tema aplica su fondo, radio (12, 24 y 4 px) y color-scheme; tras recargar se mantiene oceano, y sin nada guardado se sigue la preferencia del sistema.
📘 Concepto: Si tu diseño no tiene temas extra, los
.darkde la sección 8 te bastan. Una clase para dos estados es más simple que un atributo para cuatro.
Temas por zonas
Como las variables se resuelven en cada elemento, se puede poner un tema a una parte de la página: una cabecera oscura en una web clara, o una tarjeta clara en un panel oscuro. Hay tres formas de hacerlo, y dos fallan. Todas están dentro de un panel data-tema="oscuro":
<!-- 1. dark: mira a CUALQUIER antepasado: sigue en oscuro -->
<section data-tema="claro" class="bg-white p-4 text-gray-900 dark:bg-gray-800 dark:text-gray-100">...</section>
<!-- 2. Fondo con token, pero texto heredado: casi blanco sobre blanco -->
<section data-tema="claro" class="bg-superficie p-4">...</section>
<!-- 3. Correcto -->
<section data-tema="claro" class="bg-superficie p-4 text-tinta">...</section>

Figura 11. La segunda caja no es un fallo de la captura: su texto existe, en un color casi blanco.
- En la caja 1, el selector
:where([data-tema=oscuro] *)sigue siendo cierto dentro de la isla.dark:no entiende de “el antepasado más cercano”. - En la caja 2,
colorse hereda como valor ya calculado. Elbodylo resolvió con el tema oscuro y la isla recibe ese color claro, aunque su--color-tintahaya cambiado. - La caja 3 vuelve a pedir
text-tintay el navegador resuelve la variable con los valores de la isla.
La regla: cada contenedor con data-tema debe poner su propio bg-* y text-*. Para zonas, usa tokens: ya ves que dark: no sirve.
10. Imágenes, iconos, sombras y gráficos
El color del texto y del fondo es solo una parte. Esto es lo que suele quedar sin revisar:
<!-- Logotipo con dos versiones -->
<img src="/logo-claro.svg" alt="EMPRESA" class="dark:hidden">
<img src="/logo-oscuro.svg" alt="EMPRESA" class="hidden dark:block">
<!-- Icono monocromo negro en un fichero -->
<img src="/icono-negro.svg" alt="" class="inline-block dark:invert">
<!-- SVG en línea: hereda el color del texto -->
<svg class="size-10 text-gray-700 dark:text-gray-200" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M1 20h22"/>
</svg>
<!-- Foto: se rebaja un poco para que no deslumbre -->
<img src="/foto.svg" alt="Portátil sobre una mesa" class="rounded-lg dark:brightness-75">
<!-- Sombra: en oscuro se sustituye por un borde tenue -->
<div class="rounded-xl bg-white p-4 shadow-lg dark:bg-gray-900 dark:shadow-none dark:ring-1 dark:ring-white/10">...</div>
<!-- Elevación: sube una capa y oscurece la sombra -->
<div class="rounded-xl bg-white p-4 shadow-lg dark:bg-gray-800 dark:shadow-black/50">...</div>

Figura 12. En oscuro, el primer icono sin dark:invert casi desaparece, y la tarjeta con shadow-lg sin más se confunde con el fondo. dark:brightness-75 atenúa la foto. El borde tenue y el cambio de capa recuperan la separación.
| Elemento | Técnica | Motivo |
|---|---|---|
| Logotipo de color | Dos imágenes, dark:hidden y hidden dark:block | Permite una versión pensada para oscuro |
| Icono monocromo en fichero | dark:invert | Es el único caso en que invertir tiene sentido |
| Icono SVG en línea | stroke="currentColor" | Se pinta solo con el text-* del elemento |
| Foto | dark:brightness-75 o dark:opacity-90 | Una foto muy clara deslumbra sobre fondo oscuro |
| Sombra | dark:shadow-none dark:ring-1 dark:ring-white/10 | La sombra no se ve sobre un fondo oscuro |
| Barras y gráficos | bg-acento o dark:bg-indigo-400 | Mismo salto de tonos que el texto |
| Barras de desplazamiento | dark:scrollbar-thumb-* 🆕 v4.3 | Ver Layout |
Texto largo con prose | dark:prose-invert | Ver Tipografía |
Cuidado con <picture>
HTML permite elegir una imagen por consulta de medios:
<picture>
<source srcset="/logo-oscuro.svg" media="(prefers-color-scheme: dark)">
<img src="/logo-claro.svg" alt="EMPRESA">
</picture>
Es perfecta si sigues al sistema: el navegador solo descarga la imagen que va a usar. Pero media pregunta al sistema, no a tu clase .dark. En la prueba, con .dark puesta y el sistema en claro, currentSrc fue logo-claro.svg: logotipo oscuro sobre fondo oscuro. Con conmutador, usa la técnica dark:hidden, aunque se descarguen las dos imágenes.
Impresión
Una página en oscuro no debería gastar el tóner. print: va después de dark: en la hoja, así que gana sin necesidad de apilarlas:
<article class="bg-white dark:bg-gray-900 print:bg-white print:text-black">...</article>
Medido en un elemento con .dark activa y emulación de impresión: la clase print: se aplicó sobre dark:, tanto en 4.3.3 como en 3.4.19 con media y selector. Con darkMode: 'class' en v3 no ocurre, como viste en la sección 5.
11. Caso real: panel de control de la tienda 🔄 v4.0
🔄 Cambia en Tailwind 4.0. El panel depende de los tokens de
@theme, que en la LTS (Tailwind 3.4) no existen en CSS. Si usas la LTS: definefondo,superficie,linea,tintay demás con el esquema de canales de la sección 8; el HTML ytema.jsno cambian.
Reunimos todo en el panel de EMPRESA: tokens para las superficies, dark: para las etiquetas de color, conmutador de tres estados y <head> sin parpadeo. Los datos salen de la API de ejemplo del curso. app.css es el de la sección 8 y tema.js, el de la sección 6.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tienda EMPRESA</title>
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f3f4f6">
<script>
(function () {
let guardado = null;
try {
guardado = localStorage.getItem('tema');
} catch {
// Sin acceso a localStorage: seguimos la preferencia del sistema
}
const sistemaOscuro = matchMedia('(prefers-color-scheme: dark)').matches;
const oscuro = guardado === 'oscuro' || (guardado !== 'claro' && sistemaOscuro);
document.documentElement.classList.toggle('dark', oscuro);
})();
</script>
<link rel="stylesheet" href="/app.css">
</head>
<body class="bg-fondo text-tinta">
<header class="border-b border-linea bg-superficie">
<div class="mx-auto flex max-w-5xl items-center justify-between px-6 py-3">
<strong class="text-lg">EMPRESA <span class="font-normal text-tinta-suave">Panel</span></strong>
<div role="group" aria-label="Tema" class="inline-flex rounded-lg border border-linea p-0.5">
<button type="button" data-elegir-tema="claro"
class="rounded-md px-3 py-1 text-sm aria-pressed:bg-acento aria-pressed:text-sobre-acento">Claro</button>
<button type="button" data-elegir-tema="oscuro"
class="rounded-md px-3 py-1 text-sm aria-pressed:bg-acento aria-pressed:text-sobre-acento">Oscuro</button>
<button type="button" data-elegir-tema="sistema"
class="rounded-md px-3 py-1 text-sm aria-pressed:bg-acento aria-pressed:text-sobre-acento">Sistema</button>
</div>
</div>
</header>
<main class="mx-auto max-w-5xl space-y-6 p-6">
<section class="grid gap-4 md:grid-cols-3">
<article class="rounded-xl border border-linea bg-superficie p-4 shadow-sm dark:shadow-none">
<p class="text-sm text-tinta-suave">Pedidos entregados</p>
<p class="mt-1 text-2xl font-bold">18</p>
<p class="mt-1 text-xs text-tinta-suave">De un total de 24</p>
</article>
<!-- Dos tarjetas más con la misma estructura -->
</section>
<section class="grid gap-4 md:grid-cols-3">
<div class="overflow-x-auto rounded-xl border border-linea bg-superficie md:col-span-2">
<table class="w-full text-left text-sm">
<thead class="bg-superficie-alta text-tinta-suave">
<tr>
<th class="px-3 py-2 font-medium">Pedido</th>
<th class="px-3 py-2 font-medium">Cliente</th>
<th class="px-3 py-2 font-medium">Fecha</th>
<th class="px-3 py-2 text-right font-medium">Importe</th>
<th class="px-3 py-2 font-medium">Estado</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-linea">
<td class="px-3 py-2 font-medium">5001</td>
<td class="px-3 py-2 text-tinta-suave">Talleres Martín S.L.</td>
<td class="px-3 py-2">15/01/2025</td>
<td class="px-3 py-2 text-right tabular-nums">1.802,90 €</td>
<td class="px-3 py-2">
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800
dark:bg-green-950 dark:text-green-300">Entregado</span>
</td>
</tr>
<!-- Más filas: bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300 para Enviado
y bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 para Pendiente -->
</tbody>
</table>
</div>
<div class="rounded-xl border border-linea bg-superficie p-4">
<h2 class="mb-3 font-bold">Pedidos por estado</h2>
<div class="space-y-3">
<div class="flex items-center gap-3 text-sm">
<span class="w-24 shrink-0 text-tinta-suave">Entregados</span>
<div class="h-3 grow rounded-full bg-superficie-alta">
<div class="h-3 rounded-full bg-acento" style="width: 75%"></div>
</div>
</div>
<!-- Una fila por estado -->
</div>
</div>
</section>
</main>
<script type="module" src="/tema.js"></script>
</body>
</html>

Figura 13. El panel en claro, con los datos de pedidos de la tienda.

Figura 14. El mismo HTML en oscuro. Solo cambia una clase en <html>.
Mira lo poco que dice el HTML sobre el modo oscuro: dark:shadow-none y las etiquetas de estado. El resto lo hacen los tokens. Y las etiquetas siguen con dark: porque son colores concretos de estado que no cambian de papel. En el ejercicio 7 las convertirás en tokens tú mismo.
⚠️ Importante: Al probar el panel se detectó el error de la sección 5: al elegir “Claro” con el sistema en oscuro, la etiqueta
metadejaba los controles nativos en oscuro. Por eso la hoja de la sección 8 incluye:root { color-scheme: light; }. Tras añadirlo,color-schemedalighten ese caso.
12. Depuración: el modo oscuro no funciona
| Síntoma | Causa probable | Solución |
|---|---|---|
Pongo .dark y no cambia nada | Falta @custom-variant dark: dark: sigue al sistema | Añádelo tras @import (v3: darkMode: 'selector') |
dark: cambia con el sistema, no con mi botón | Lo mismo | Lo mismo |
Funciona en todo menos en <html> | Variante escrita como &:where(.dark *): excluye al propio elemento | &:where(.dark, .dark *) |
| El fondo de la página no cambia del todo | .dark está en <body> y los fondos o scheme están en <html> | Pon .dark en <html> |
| Al pasar el puntero se ilumina en claro | Falta dark:hover: | Añádelo (sección 3) |
| Controles nativos en claro sobre fondo oscuro | Falta color-scheme | dark:scheme-dark en <html> |
| Controles oscuros aunque el usuario eligió claro | Etiqueta meta y sin scheme-light | scheme-light dark:scheme-dark |
| Destello claro al recargar | Tema aplicado en un módulo o al final del body | Script en línea en <head> |
| Se olvida el tema al recargar | Se aplica la clase pero no se guarda, o el <head> no lo lee | Revisa localStorage en Application de las herramientas de desarrollo |
Un <picture> no sigue el botón | media pregunta al sistema | dark:hidden y hidden dark:block |
bg-papel no cambia dentro de una sección | Variable propia en @theme | @theme inline o redefine --color-* |
| El texto se ve mal dentro de una zona con tema | color heredado ya calculado | Añade text-tinta en la zona |
dark: sigue activo dentro de una zona clara | dark: mira a cualquier antepasado | Usa tokens en la zona |
Tres pasos que resuelven casi todo:
- Inspecciona
<html>en las herramientas de desarrollo. ¿Tiene la clase o el atributo? Si no, el problema está en JavaScript. - Inspecciona el elemento y busca la regla
dark:. Si está como@media (prefers-color-scheme: dark), te falta@custom-variant. Si aparece tachada, otra regla gana: mira su especificidad. - Consulta la variable en la pestaña Computed. Muestra el valor real de
--color-superficieen ese elemento.
13. Ejercicios
Ejercicio 1: Ficha de pedido en oscuro
Parte de esta ficha clara y añade su versión oscura siguiendo al sistema, con los pares de la tabla de la sección 3. La página tiene fondo bg-gray-100.
<article class="max-w-md rounded-xl border border-gray-200 bg-white p-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900">Pedido 5001</h2>
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Entregado</span>
</div>
<p class="mt-1 text-sm text-gray-600">Enviado el 17 de enero de 2025</p>
<div class="mt-3 border-t border-gray-200 pt-3">
<a href="#" class="font-medium text-indigo-600 hover:underline">Ver factura</a>
</div>
</article>
Ver solución
<body class="bg-gray-100 p-8 dark:bg-gray-950">
<article class="max-w-md rounded-xl border border-gray-200 bg-white p-5
dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-100">Pedido 5001</h2>
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800
dark:bg-green-950 dark:text-green-300">Entregado</span>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Enviado el 17 de enero de 2025</p>
<div class="mt-3 border-t border-gray-200 pt-3 dark:border-gray-800">
<a href="#" class="font-medium text-indigo-600 hover:underline dark:text-indigo-400">Ver factura</a>
</div>
</article>
</body>
hover:underline no necesita pareja: el subrayado no depende del tema, y usa el color del texto.

Figura 15. La ficha del pedido siguiendo al sistema.
Ejercicio 2: Navegación con todos los estados
Crea dos enlaces de navegación: uno normal, con fondo al pasar el puntero y anillo de foco de teclado, y otro marcado con aria-current="page", con fondo índigo. En oscuro, ningún estado debe mostrar un color claro.
Ver solución
<nav class="flex gap-2">
<a href="#" class="rounded-lg px-3 py-2 text-gray-700 hover:bg-gray-200
focus-visible:outline-2 focus-visible:outline-indigo-600
dark:text-gray-300 dark:hover:bg-gray-800
dark:focus-visible:outline-indigo-400">Catálogo</a>
<a href="#" aria-current="page"
class="rounded-lg bg-indigo-100 px-3 py-2 font-medium text-indigo-800 hover:bg-indigo-200
dark:bg-indigo-950 dark:text-indigo-200 dark:hover:bg-indigo-900">Pedidos</a>
</nav>
Cada color en un estado tiene su pareja. Medido en oscuro: el hover del primer enlace da gray-800 y el del segundo indigo-900, y el foco de teclado se dibuja sólido en indigo-400.
En v3, outline-2 solo fija el grosor y hay que añadir outline para que se dibuje: focus-visible:outline focus-visible:outline-2. En v4, outline-2 ya incluye el estilo.
Ejercicio 3: Formulario oscuro completo
Crea un formulario con fecha, <select> y casilla, que siga al sistema. Los controles nativos deben ser oscuros desde el primer momento, antes de que cargue la hoja de estilos. La casilla debe usar el índigo que corresponda a cada modo.
Ver solución
<!doctype html>
<html lang="es" class="dark:scheme-dark">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<title>Entrega</title>
<link rel="stylesheet" href="/app.css">
</head>
<body class="bg-gray-100 p-8 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<form class="max-w-sm space-y-3 rounded-xl bg-white p-5 dark:bg-gray-900">
<label class="block text-sm">Fecha de entrega
<input type="date" class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-700">
</label>
<label class="block text-sm">Sección
<select class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-700">
<option>Portátiles</option>
<option>Sobremesa</option>
</select>
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" checked class="accent-indigo-600 dark:accent-indigo-400"> Avisarme por correo
</label>
</form>
</body>
</html>
La etiqueta meta cubre el tiempo hasta que llega la hoja y dark:scheme-dark deja el valor fijado. Aquí no hace falta scheme-light, porque sin botón no se da el caso de “sistema oscuro y usuario en claro”. En v3, cambia dark:scheme-dark por dark:[color-scheme:dark].

Figura 16. Con color-scheme, el icono del calendario y la flecha del <select> se ven bien.
Ejercicio 4: De darkMode a @custom-variant
Un proyecto v3 tiene este ajuste y lo quieres pasar a v4 sin tocar el HTML. Escribe el CSS y comprueba que funciona con <html data-modo="oscuro">.
// tailwind.config.js
export default {
darkMode: ['selector', '[data-modo="oscuro"]'],
};
Ver solución
@import "tailwindcss";
@custom-variant dark (&:where([data-modo=oscuro], [data-modo=oscuro] *));
<html lang="es" data-modo="oscuro" class="dark:scheme-dark">
<body class="bg-gray-100 p-8 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<p class="rounded bg-white p-4 dark:bg-gray-900">Pedido 5001</p>
</body>
</html>
Medido: body da gray-950 y color-scheme da dark, porque dark:scheme-dark está en el mismo <html> que lleva el atributo. Funciona gracias a la primera parte del selector. Con solo [data-modo=oscuro] * no se aplicaría.
Ejercicio 5: Conmutador de dos estados
Crea un botón “Modo oscuro” que use aria-pressed, recuerde la elección y, la primera vez, siga al sistema. Debe arrancar sin parpadeo.
Ver solución
<!doctype html>
<html lang="es" class="scheme-light dark:scheme-dark">
<head>
<meta charset="utf-8">
<title>Tienda EMPRESA</title>
<script>
(function () {
let guardado = null;
try {
guardado = localStorage.getItem('tema');
} catch {}
const oscuro = guardado
? guardado === 'oscuro'
: matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', oscuro);
})();
</script>
<link rel="stylesheet" href="/app.css">
</head>
<body class="bg-white p-6 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<button id="tema" type="button" aria-pressed="false"
class="rounded-lg border border-gray-300 px-3 py-1.5
aria-pressed:bg-gray-800 aria-pressed:text-white dark:border-gray-700">
Modo oscuro
</button>
<script>
const boton = document.querySelector('#tema');
const raiz = document.documentElement;
boton.setAttribute('aria-pressed', String(raiz.classList.contains('dark')));
boton.addEventListener('click', () => {
const oscuro = raiz.classList.toggle('dark');
boton.setAttribute('aria-pressed', String(oscuro));
try {
localStorage.setItem('tema', oscuro ? 'oscuro' : 'claro');
} catch {
// Sin almacenamiento: dura hasta recargar
}
});
</script>
</body>
</html>
classList.toggle('dark') sin segundo argumento devuelve si la clase ha quedado puesta, y eso se aprovecha para aria-pressed y para guardar. Medido: arranca en false, con el clic pasa a true y oscuro, y tras recargar sigue true y oscuro. Requiere @custom-variant dark (&:where(.dark, .dark *));.
El texto del botón no cambia: con aria-pressed, “Modo oscuro” más “pulsado” ya se entiende. Cambiar a la vez texto y aria-pressed confundiría a un lector de pantalla.
Ejercicio 6: Encuentra los cuatro errores
Esta página debe tener conmutador manual. El usuario se queja de cuatro cosas: el botón no hace nada, hay un destello claro al recargar, el <input type="date"> tiene un icono oscuro invisible y, al pasar el puntero por el enlace, aparece un fondo blanco. Encuentra la causa de cada una.
@import "tailwindcss";
<!doctype html>
<html lang="es">
<head>
<link rel="stylesheet" href="/app.css">
</head>
<body class="bg-white dark:bg-gray-950 dark:text-gray-100">
<a href="#" class="dark:bg-gray-800 hover:bg-gray-100">Pedidos</a>
<input type="date">
<button id="b">Tema</button>
<script type="module" src="/tema.js"></script>
</body>
</html>
Ver solución
- El botón no hace nada. Falta
@custom-variant dark (&:where(.dark, .dark *));. Sin ella,dark:obedece al sistema y la clase.darkno tiene ningún efecto. - Destello claro. El tema solo se aplica desde
tema.js, un módulo que se ejecuta después de analizar el HTML. Falta el script en línea en<head>. - Icono invisible. Falta
color-scheme. Añade a<html>las clasesscheme-light dark:scheme-dark. - Fondo blanco en
hover.hover:bg-gray-100tiene más especificidad quedark:bg-gray-800. Faltadark:hover:bg-gray-700.
Fíjate en que ninguno de los cuatro da error de compilación: Tailwind genera CSS válido en todos los casos.
Ejercicio 7: Estados con tokens
Convierte las etiquetas “Entregado” y “Pendiente” a tokens: bg-exito-fondo text-exito y bg-aviso-fondo text-aviso, sin ningún dark: en el HTML. Usa .dark como selector.
Ver solución
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-superficie: var(--color-white);
--color-tinta: var(--color-gray-900);
--color-exito: var(--color-green-700);
--color-exito-fondo: var(--color-green-100);
--color-aviso: var(--color-amber-800);
--color-aviso-fondo: var(--color-amber-100);
}
@layer base {
.dark {
color-scheme: dark;
--color-superficie: var(--color-gray-900);
--color-tinta: var(--color-gray-100);
--color-exito: var(--color-green-300);
--color-exito-fondo: var(--color-green-950);
--color-aviso: var(--color-amber-300);
--color-aviso-fondo: var(--color-amber-950);
}
}
<div class="flex gap-2 bg-superficie p-4 text-tinta">
<span class="rounded-full bg-exito-fondo px-2 py-0.5 text-xs font-medium text-exito">Entregado</span>
<span class="rounded-full bg-aviso-fondo px-2 py-0.5 text-xs font-medium text-aviso">Pendiente</span>
</div>

Figura 17. Las etiquetas de estado con .dark, sin ninguna clase dark: en el HTML.
Si mañana cambias el verde de “éxito”, tocas una sola línea.
Ejercicio 8: Tema “vino”
Añade un tema oscuro vino al sistema de data-tema: granates y texto rosado. Los botones deben conservar su dark:ring-1 dark:ring-white/20, así que dark: debe activarse también con este tema.
Ver solución
/* 1. Añade el tema a la variante */
@custom-variant dark (&:where([data-tema=oscuro], [data-tema=oscuro] *, [data-tema=vino], [data-tema=vino] *));
/* 2. Añade el bloque en @layer base */
@layer base {
[data-tema=vino] {
color-scheme: dark;
--color-fondo: #2b0a16;
--color-superficie: #431222;
--color-linea: #6d2740;
--color-tinta: #fbeef2;
--color-acento: #f4a6bc;
--color-sobre-acento: #2b0a16;
}
}
<html lang="es" data-tema="vino">
<body class="bg-fondo p-8 text-tinta">
<article class="max-w-sm rounded-xl border border-linea bg-superficie p-5">
<h2 class="font-bold">Torre Oficina i5</h2>
<button class="mt-3 rounded-lg bg-acento px-3 py-1.5 text-sm font-medium text-sobre-acento
dark:ring-1 dark:ring-white/20">Añadir a la cesta</button>
</article>
</body>
</html>
No hace falta tocar el HTML de ningún componente. Medido: color-scheme da dark y el botón tiene su anillo.

Figura 18. El tema “vino” con data-tema="vino" en <html>.
Ejercicio 9 (propuesto): Barra del navegador por tema
Amplía el selector de temas de la sección 9 para que <meta name="theme-color"> cambie con cada tema, y que el valor esté en el CSS y no en JavaScript. Pista: getComputedStyle(document.documentElement).getPropertyValue('--color-fondo') te da el color ya resuelto, aunque en el CSS se haya escrito con var().
Ejercicio 10 (propuesto): Cabecera siempre oscura
Haz que la cabecera de la tienda sea oscura también con la página en tema claro, con los tokens de la sección 9 y data-tema="oscuro" en el <header>. Comprueba que un <select> dentro de la cabecera sale con controles oscuros y explica por qué.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Variante oscura | dark: | Por defecto, @media (prefers-color-scheme: dark) |
| Capas de superficie | gray-950, 900, 800 | Más alto es más claro; evita el negro puro |
| Regla de tonos | 600 a 400 | Los textos de color suben unos 200 puntos en oscuro |
| Estados | dark:hover: | hover: pesa más que dark:: necesita su pareja |
| Solo en claro | not-dark: 🆕 v4.0 | Negación de la variante |
| Controles nativos | scheme-* 🆕 v4.0 | En v3, [color-scheme:dark] |
| Aviso temprano | <meta name="color-scheme"> | Oscuro antes de cargar el CSS; manda sobre normal |
| Modo manual | @custom-variant dark 🔄 v4.0 | En v3, darkMode: 'selector' |
| Selector seguro | :where(.dark, .dark *) | Especificidad cero e incluye al propio elemento |
| Conmutador | scheme-light dark:scheme-dark | Pon siempre los dos lados |
| Sin parpadeo | Script en línea en <head> | Bloquea el análisis; así no se pinta sin tema |
| Tres estados | localStorage, matchMedia, storage | “Sistema” es no tener nada guardado |
| CSS propio | @variant dark 🆕 v4.0 | Reutiliza la variante; en v3, @apply dark:... |
| Tokens | @theme 🔄 v4.0 | Nombra el papel; el valor cambia por antepasado |
| Variable propia | @theme inline 🆕 v4.0 | @theme la resuelve en :root |
| Temas múltiples | [data-tema=x] | Un tema es un bloque de variables más su color-scheme |
| Zonas | data-tema en una sección | Repite bg-* y text-*; dark: no sirve |
| Imágenes | dark:hidden, dark:invert, dark:brightness-75 | <picture> solo escucha al sistema |
| Sombras | dark:shadow-none dark:ring-1 | En oscuro separa el color, no la sombra |
| Impresión | print: | Va después de dark: en la hoja |