En esta página

Dark mode y variantes avanzadas

12 min lectura TextoCap. 3 — Estilos visuales

Dark mode y variantes avanzadas en Tailwind

Una de las características más potentes de Tailwind es su sistema de variantes: prefijos que aplican estilos condicionalmente según el estado del elemento, el estado del DOM, o las preferencias del usuario. El dark mode es solo una de las muchas variantes disponibles.

Dark mode con la variante dark:

Estrategia media (prefers-color-scheme)

Por defecto, Tailwind usa la preferencia de color del sistema operativo:

<!-- Se oscurece automáticamente cuando el OS está en modo oscuro -->
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1 class="text-3xl font-bold">Título</h1>
  <p class="text-gray-600 dark:text-gray-400">Descripción</p>
</body>

No necesitas ninguna configuración extra. Tailwind escucha @media (prefers-color-scheme: dark) automáticamente.

Estrategia class (toggle manual)

Para implementar un toggle de tema que el usuario pueda controlar:

/* styles.css */
@import "tailwindcss";

@theme {
  /* Configurar la estrategia 'class' para dark mode */
}

/* Tailwind v4: configura dark mode con selector */
@custom-variant dark (&:is(.dark *));

Con esta configuración, añades/quitas la clase dark al elemento <html>:

<!-- Modo claro -->
<html class="">...</html>

<!-- Modo oscuro -->
<html class="dark">...</html>
// Toggle con JavaScript
const toggle = document.querySelector('#theme-toggle');
toggle.addEventListener('click', () => {
  document.documentElement.classList.toggle('dark');
  // Persiste en localStorage
  const isDark = document.documentElement.classList.contains('dark');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
});

// Restaurar al cargar
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
  document.documentElement.classList.add('dark');
}

Paleta de colores para dark mode

El truco es definir una paleta de superficies semántica:

<!-- Patrones de color para dark mode -->

<!-- Fondo principal -->
<div class="bg-white dark:bg-gray-950">...</div>

<!-- Fondo de tarjetas/componentes -->
<div class="bg-gray-50 dark:bg-gray-900">...</div>

<!-- Fondo elevado (sobre tarjetas) -->
<div class="bg-white dark:bg-gray-800">...</div>

<!-- Bordes -->
<div class="border border-gray-200 dark:border-gray-700">...</div>

<!-- Texto principal -->
<p class="text-gray-900 dark:text-white">...</p>

<!-- Texto secundario -->
<p class="text-gray-600 dark:text-gray-400">...</p>

<!-- Texto de placeholder/deshabilitado -->
<p class="text-gray-400 dark:text-gray-600">...</p>

Botón de toggle de tema completo

<!-- Botón de toggle de tema -->
<button
  id="theme-toggle"
  type="button"
  class="relative w-12 h-6 rounded-full transition-colors duration-300
         bg-gray-200 dark:bg-blue-600
         focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
  aria-label="Cambiar tema"
>
  <!-- Círculo que se desliza -->
  <span
    class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-sm
           transition-transform duration-300
           translate-x-0 dark:translate-x-6"
  ></span>

  <!-- Iconos de sol/luna -->
  <span class="absolute inset-0 flex items-center justify-start pl-1.5 text-xs dark:hidden">
    ☀️
  </span>
  <span class="absolute inset-0 flex items-center justify-end pr-1.5 text-xs hidden dark:flex">
    🌙
  </span>
</button>

Variante group:

Ya vista brevemente antes, group es esencial para efectos hover complejos:

<!-- Grupo con múltiples hijos que reaccionan -->
<div class="group rounded-2xl overflow-hidden shadow-md hover:shadow-xl transition-shadow">

  <!-- La imagen escala en hover del padre -->
  <div class="overflow-hidden h-48">
    <img
      src="course.jpg"
      alt="Portada"
      class="w-full h-full object-cover transition-transform duration-500
             group-hover:scale-110"
    />
  </div>

  <!-- El badge cambia de color -->
  <div class="p-5">
    <span
      class="text-xs font-semibold px-2 py-1 rounded-full
             bg-blue-100 text-blue-700
             group-hover:bg-blue-600 group-hover:text-white
             transition-colors"
    >
      Nuevo
    </span>
    <h3 class="font-bold text-gray-900 mt-2 group-hover:text-blue-600 transition-colors">
      Título del curso
    </h3>
  </div>
</div>

Grupos anidados con group/{name}

<!-- Múltiples grupos en el mismo DOM -->
<div class="group/card rounded-xl p-6 hover:bg-blue-50">

  <div class="group/header flex items-center justify-between mb-4">
    <h3 class="font-bold group-hover/card:text-blue-600">Título</h3>
    <button
      type="button"
      class="opacity-0 group-hover/header:opacity-100
             group-hover/card:opacity-50 hover:!opacity-100"
    >
      ✏️
    </button>
  </div>

  <p class="text-gray-500">Contenido de la tarjeta</p>
</div>

Variante peer:

peer permite que un elemento aplique estilos a su hermano siguiente según su estado:

<!-- Validación de formulario con peer -->
<div>
  <input
    type="email"
    class="peer w-full border rounded-lg px-4 py-2 outline-none
           border-gray-300 focus:border-blue-500
           invalid:border-red-400 invalid:bg-red-50"
    placeholder="[email protected]"
    required
  />
  <!-- Este párrafo es hermano del input — usa peer-invalid: -->
  <p
    class="mt-1 text-sm text-red-500
           invisible peer-invalid:visible"
  >
    Por favor ingresa un email válido
  </p>
</div>

<!-- Checkbox personalizado con peer -->
<label class="flex items-center gap-3 cursor-pointer select-none">
  <input type="checkbox" class="peer sr-only" />
  <div
    class="w-5 h-5 rounded border-2 border-gray-300
           peer-checked:bg-blue-600 peer-checked:border-blue-600
           peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
           transition-colors flex items-center justify-center"
  >
    <svg
      class="w-3 h-3 text-white opacity-0 peer-checked:opacity-100 transition-opacity"
      viewBox="0 0 12 12" fill="currentColor"
    >
      <path d="M10 3L5 8.5 2 5.5l-1 1 4 4 6-7-1-1z"/>
    </svg>
  </div>
  <span class="text-gray-700 peer-checked:text-blue-700 transition-colors">
    Acepto los términos y condiciones
  </span>
</label>

Variante has:

has: aplica estilos a un padre basándose en sus descendientes:

<!-- Formulario que cambia cuando tiene un input con focus -->
<div
  class="border rounded-xl p-4 transition-colors
         border-gray-200
         has-[input:focus]:border-blue-400
         has-[input:focus]:bg-blue-50/30"
>
  <label class="block text-sm font-medium text-gray-700 mb-2">Email</label>
  <input
    type="email"
    class="w-full outline-none bg-transparent text-gray-900 placeholder:text-gray-400"
    placeholder="[email protected]"
  />
</div>

<!-- Tarjeta seleccionable -->
<label
  class="block border-2 rounded-xl p-6 cursor-pointer
         border-gray-200 hover:border-gray-300
         has-[input:checked]:border-blue-500 has-[input:checked]:bg-blue-50
         transition-colors"
>
  <input type="radio" name="plan" value="pro" class="sr-only" />
  <div class="flex items-center justify-between">
    <div>
      <p class="font-semibold text-gray-900">Plan Pro</p>
      <p class="text-sm text-gray-500">$19/mes</p>
    </div>
    <div
      class="w-5 h-5 rounded-full border-2 border-gray-300
             has-[input:checked]/label:border-blue-500"
    ></div>
  </div>
</label>

Variantes de datos: data-*

<!-- Activar variantes con atributos data-* -->
<div data-state="open" class="data-[state=open]:block hidden">
  Panel visible cuando data-state="open"
</div>

<button
  data-loading="true"
  class="bg-blue-600 text-white px-4 py-2 rounded-lg
         data-[loading=true]:opacity-50 data-[loading=true]:cursor-not-allowed"
>
  Enviar
</button>

<!-- Variantes ARIA -->
<div
  role="button"
  aria-expanded="false"
  class="aria-expanded:rotate-180 transition-transform"
>

</div>

Variantes de medios: motion-safe y motion-reduce

<!-- Respetar las preferencias de movimiento del usuario -->
<div
  class="motion-safe:transition-transform motion-safe:hover:scale-105
         motion-reduce:transition-none"
>
  <!-- Se anima solo si el usuario no tiene reducción de movimiento activa -->
</div>

<div
  class="motion-safe:animate-bounce motion-reduce:opacity-75"
>
  Indicador de carga
</div>

Variantes print

<!-- Estilos solo para impresión -->
<nav class="print:hidden">
  <!-- La navegación no se imprime -->
</nav>

<article class="print:text-black print:bg-white">
  <!-- El artículo se imprime limpio -->
</article>

Variante forced-colors

<!-- Accesibilidad en modo de alto contraste de Windows -->
<button
  class="bg-blue-600 text-white border border-transparent
         forced-colors:border-current"
>
  Botón accesible en alto contraste
</button>

Resumen

Las variantes de Tailwind transforman HTML semántico en interfaces reactivas sin JavaScript para los estados CSS. Las más importantes son: dark: para modo oscuro, group-hover: para efectos padre-hijo, peer-checked: para formularios personalizados, has: para estilos basados en descendientes, y data-[attr=value]: para integración con JavaScript. La variante motion-reduce: es crucial para accesibilidad.

Estrategia de dark mode: class vs media
Por defecto Tailwind usa la estrategia 'media' (usa prefers-color-scheme del OS). Para un toggle manual, configura la estrategia 'class' en @theme y añade/quita la clase 'dark' en el elemento html. La estrategia 'class' es más flexible para apps con preferencia guardada.
has: — el selector más poderoso de CSS moderno
La variante has: permite estilizar un elemento basándose en sus descendientes. Ejemplo: has-[input:checked]:bg-blue-50 aplica fondo azul al contenedor cuando un input dentro está marcado. Es como :has() nativo de CSS pero en Tailwind.
<!-- dark: aplica cuando el elemento ancestro tiene class="dark" -->
<!-- O cuando el sistema operativo tiene modo oscuro (media strategy) -->

<div class="bg-white dark:bg-gray-900 min-h-screen">

  <!-- Tarjeta con dark mode -->
  <div
    class="bg-white dark:bg-gray-800
           border border-gray-200 dark:border-gray-700
           rounded-xl p-6 shadow-sm"
  >
    <h2 class="text-gray-900 dark:text-white font-bold text-xl mb-3">
      Título de la tarjeta
    </h2>
    <p class="text-gray-600 dark:text-gray-400 leading-relaxed">
      Contenido que se adapta automáticamente al modo oscuro.
    </p>
    <button
      class="mt-4 bg-blue-600 hover:bg-blue-700
             dark:bg-blue-500 dark:hover:bg-blue-400
             text-white font-medium px-4 py-2 rounded-lg
             transition-colors"
    >
      Acción principal
    </button>
  </div>

</div>