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: con hover:, md: y group-*, y evitar el fallo clásico del hover claro sobre fondo oscuro
  • Explicar qué hace color-scheme y usar scheme-* para que formularios y barras de desplazamiento también se oscurezcan
  • Activar el modo oscuro a mano con @custom-variant dark y conocer su equivalente darkMode en 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é @theme falla en una sección y @theme inline no
  • 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:

  1. 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:.
  2. Quién decide que la interfaz es oscura: el sistema operativo, el usuario con un botón o ambos.
  3. 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.css sin 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>

Cuatro tarjetas: la original clara, otra con invert donde la imagen se vuelve azul y el precio verde, otra con invert y hue-rotate con colores lavados y una diseñada para oscuro con superficie gris azulada

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

La misma tarjeta de producto en claro, con fondo gris claro y tarjeta blanca, y en oscuro, con fondo casi negro y tarjeta gris azulada con borde

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 h2 y el precio no llevan dark:. Su color se hereda de body, 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>

A la izquierda, todo negro con texto blanco y sin distinguir la tarjeta; a la derecha, tres grises azulados que separan fondo, tarjeta y línea de pedido

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-100 en lugar de blanco puro sobre gray-950 no 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 ejemplo hover: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.

Dos paneles oscuros con el mismo formulario. A la izquierda, sin color-scheme: icono del calendario negro, deslizador con carril blanco, barra de desplazamiento clara. A la derecha, con scheme-dark, todos los controles nativos en oscuro

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] y dark:[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: escribe darkMode: 'selector' para usar la clase .dark, o darkMode: ['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 y darkMode: '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): @variant queda 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.

Diagrama: un script en head consulta localStorage. Si vale oscuro pone la clase dark, si vale claro la quita y si no hay nada pregunta al sistema operativo

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.js con type="module" o defer, 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:

Dos capturas de la misma carga. La primera, a los 450 ms, muestra la página en claro. La segunda, ya cargado el script, muestra la página en oscuro

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 si oscuro es 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. El if respeta al usuario que eligió un tema fijo.
  • El evento storage solo se dispara en las demás pestañas, no en la que hizo el cambio. Por eso sincroniza sin bucles.
  • theme-color es 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.

Panel con el selector de tres botones arriba, el botón Oscuro resaltado y la tarjeta de producto en modo oscuro

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 de tema.js calculan 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 clave tema, 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.js y no generan variables. Si usas la LTS: crea las variables tú mismo como canales RGB y regístralas como fondo: 'rgb(var(--fondo) / <alpha-value>)' en theme.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>

Diagrama: la clase bg-superficie genera background-color var(--color-superficie), que vale white en root, slate-900 en dark y otro color en el tema océano

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) y dark: 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-900 pasarí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 inline no existe en la LTS (Tailwind 3.4). Si usas la LTS: no tienes esta trampa, porque rgb(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>

Dos secciones con data-tema oscuro. La que usa @theme se ve blanca, y la que usa @theme inline se ve oscura

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: usa darkMode: ['variant', '&:where([data-tema=oscuro], [data-tema=oscuro] *, [data-tema=oceano], [data-tema=oceano] *)'], y para tema-bosque: usa addVariant('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>

Cuadrícula con la misma tienda en cuatro temas: claro, oscuro, océano con esquinas muy redondeadas y bosque verde con bordes dobles y casi rectos

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-tarjeta crea rounded-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-1 afecta 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 .dark de 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>

Tres cajas dentro de un panel oscuro. La primera sigue oscura. La segunda es blanca con texto casi invisible. La tercera es una isla clara correcta

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, color se hereda como valor ya calculado. El body lo resolvió con el tema oscuro y la isla recibe ese color claro, aunque su --color-tinta haya cambiado.
  • La caja 3 vuelve a pedir text-tinta y 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>

Dos mosaicos, en claro y en oscuro, con logotipos, iconos, fotos y tres tarjetas con diferentes sombras

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: define fondo, superficie, linea, tinta y demás con el esquema de canales de la sección 8; el HTML y tema.js no 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>

Panel de la tienda en claro con tres tarjetas de datos, tabla de pedidos con etiquetas de estado de colores y gráfico de barras

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

El mismo panel en oscuro, con capas gris azuladas, etiquetas de estado oscuras y barras en índigo claro

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 meta dejaba los controles nativos en oscuro. Por eso la hoja de la sección 8 incluye :root { color-scheme: light; }. Tras añadirlo, color-scheme da light en 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:

  1. Inspecciona <html> en las herramientas de desarrollo. ¿Tiene la clase o el atributo? Si no, el problema está en JavaScript.
  2. 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.
  3. Consulta la variable en la pestaña Computed. Muestra el valor real de --color-superficie en 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.

La ficha del pedido 5001 en claro y en oscuro

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].

Formulario oscuro con icono de calendario claro, select y casilla marcada en índigo

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
  1. El botón no hace nada. Falta @custom-variant dark (&:where(.dark, .dark *));. Sin ella, dark: obedece al sistema y la clase .dark no tiene ningún efecto.
  2. 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>.
  3. Icono invisible. Falta color-scheme. Añade a <html> las clases scheme-light dark:scheme-dark.
  4. Fondo blanco en hover. hover:bg-gray-100 tiene más especificidad que dark:bg-gray-800. Falta dark: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>

Etiquetas Entregado y Pendiente sobre fondo oscuro

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.

Tarjeta en tonos granate con botón rosa y anillo blanco tenue

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