← Volver al blog
·9 min de lectura

Gestion de temas oscuro y claro con CSS custom properties

FrontendCSS

Por que CSS custom properties para temas

Existen varias formas de implementar temas oscuro y claro en una aplicacion web. Las mas comunes son CSS-in-JS (styled-components, Emotion), el prefijo dark: de Tailwind CSS, y CSS custom properties (variables CSS nativas). Cada una tiene trade-offs distintos.

CSS-in-JS genera estilos en tiempo de ejecucion, lo que significa JavaScript adicional en el bundle y un costo de rendimiento en cada renderizado. Ademas, acopla tu sistema de temas a una libreria especifica de React.

Tailwind con dark: funciona bien, pero duplica clases en el markup. Un elemento que antes tenia bg-white text-gray-900 ahora necesita bg-white dark:bg-gray-900 text-gray-900 dark:text-white. En componentes con muchas propiedades visuales esto se vuelve dificil de mantener.

CSS custom properties operan a nivel del navegador sin JavaScript en runtime, no agregan peso al bundle, funcionan con cualquier framework (o sin framework), y permiten transiciones suaves entre temas con una sola linea de CSS. El cambio de tema se reduce a modificar una clase en el elemento raiz, y todos los estilos se actualizan instantaneamente por cascada.

Definiendo los tokens de color

El primer paso es definir tus colores como variables CSS con nombres semanticos. En lugar de --color-gray-100 o --color-blue-500, usa nombres que describan el proposito: --color-text, --color-background, --color-surface, --color-border.

/* styles/theme.css */

:root {
  /* Tema claro (por defecto) */
  --color-background: #ffffff;
  --color-surface: #f8f9fa;
  --color-surface-elevated: #ffffff;
  --color-text: #1a1a2e;
  --color-text-secondary: #4a4a6a;
  --color-text-muted: #8888a0;
  --color-border: #e2e2e8;
  --color-border-subtle: #f0f0f4;
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-primary-text: #ffffff;
  --color-accent: #7c3aed;
  --color-success: #16a34a;
  --color-warning: #d97706;
  --color-error: #dc2626;
  --color-code-bg: #f1f5f9;
  --color-shadow: rgba(0, 0, 0, 0.08);

  /* Tipografia y espaciado (no cambian entre temas) */
  --font-sans: 'Inter', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;
}

[data-theme="dark"] {
  --color-background: #0f0f1a;
  --color-surface: #1a1a2e;
  --color-surface-elevated: #252540;
  --color-text: #e8e8f0;
  --color-text-secondary: #a8a8c0;
  --color-text-muted: #686880;
  --color-border: #2a2a40;
  --color-border-subtle: #1f1f35;
  --color-primary: #3b82f6;
  --color-primary-hover: #60a5fa;
  --color-primary-text: #ffffff;
  --color-accent: #8b5cf6;
  --color-success: #22c55e;
  --color-warning: #f59e0b;
  --color-error: #ef4444;
  --color-code-bg: #1e1e30;
  --color-shadow: rgba(0, 0, 0, 0.3);
}

Usar [data-theme="dark"] en lugar de una clase como .dark es una preferencia que separa los datos de la presentacion. Ambos enfoques funcionan identicamente; lo importante es ser consistente.

Usando las variables en tus estilos

Una vez definidas las variables, usarlas es directo:

body {
  background-color: var(--color-background);
  color: var(--color-text);
  font-family: var(--font-sans);
  transition: background-color 0.3s ease, color 0.3s ease;
}

.card {
  background-color: var(--color-surface-elevated);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: 0 2px 8px var(--color-shadow);
  padding: 1.5rem;
}

.card h2 {
  color: var(--color-text);
  margin-bottom: 0.5rem;
}

.card p {
  color: var(--color-text-secondary);
}

.button-primary {
  background-color: var(--color-primary);
  color: var(--color-primary-text);
  border: none;
  border-radius: var(--radius-sm);
  padding: 0.5rem 1rem;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.button-primary:hover {
  background-color: var(--color-primary-hover);
}

.badge {
  background-color: var(--color-surface);
  color: var(--color-text-secondary);
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-sm);
  padding: 0.25rem 0.5rem;
  font-size: 0.875rem;
}

pre, code {
  background-color: var(--color-code-bg);
  font-family: var(--font-mono);
  border-radius: var(--radius-sm);
}

El cambio de tema ahora es trivial: al modificar el atributo data-theme en el <html>, todos los componentes se actualizan automaticamente porque las variables resuelven a valores distintos.

Implementando el toggle en JavaScript

El toggle debe hacer tres cosas: cambiar el atributo en el DOM, persistir la preferencia en localStorage, y actualizar el estado visual del boton.

// theme.js
function getStoredTheme() {
  if (typeof window === 'undefined') return 'light'
  return localStorage.getItem('theme')
}

function getSystemTheme() {
  if (typeof window === 'undefined') return 'light'
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light'
}

function getEffectiveTheme() {
  const stored = getStoredTheme()
  if (stored === 'dark' || stored === 'light') return stored
  return getSystemTheme()
}

function applyTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme)
}

function toggleTheme() {
  const current = getEffectiveTheme()
  const next = current === 'dark' ? 'light' : 'dark'
  localStorage.setItem('theme', next)
  applyTheme(next)
  return next
}

// Escuchar cambios en las preferencias del sistema
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    // Solo actualizar si el usuario no ha elegido manualmente
    if (!localStorage.getItem('theme')) {
      applyTheme(e.matches ? 'dark' : 'light')
    }
  })

La logica de prioridad es: si el usuario eligio un tema manualmente, respetar esa eleccion. Si no, seguir la preferencia del sistema operativo. Si el sistema no informa preferencia, usar el tema claro como fallback.

Evitando el FOUC (Flash of Unstyled Content)

Este es el problema mas comun al implementar temas con JavaScript. El flujo tipico es:

  1. El navegador carga el HTML con el tema claro por defecto
  2. El CSS se aplica (tema claro visible)
  3. React se hidrata y ejecuta el useEffect
  4. El useEffect lee localStorage y aplica el tema oscuro
  5. Flash visible: el usuario ve un destello del tema claro antes de que se aplique el oscuro

La solucion es un script inline bloqueante en el <head>, antes de que se renderice cualquier contenido:

<!-- En el <head>, ANTES de cualquier stylesheet -->
<script>
  (function() {
    var theme = localStorage.getItem('theme');
    if (!theme) {
      theme = window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light';
    }
    document.documentElement.setAttribute('data-theme', theme);
  })();
</script>

En Next.js con App Router, este script va en el layout raiz:

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                var theme = localStorage.getItem('theme');
                if (!theme) {
                  theme = window.matchMedia('(prefers-color-scheme: dark)').matches
                    ? 'dark'
                    : 'light';
                }
                document.documentElement.setAttribute('data-theme', theme);
              })();
            `,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

El suppressHydrationWarning es necesario porque el atributo data-theme existira en el HTML del cliente pero no en el HTML generado por el servidor, lo que normalmente causaria un warning de hidratacion.

Respetando prefers-color-scheme

Ademas de la deteccion inicial, es buena practica definir los estilos base usando la media query como fallback para usuarios sin JavaScript:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-background: #0f0f1a;
    --color-surface: #1a1a2e;
    --color-text: #e8e8f0;
    /* ... resto de variables oscuras */
  }
}

El selector :root:not([data-theme="light"]) significa: aplica el tema oscuro cuando el sistema lo prefiere, excepto si el usuario explicitamente eligio el tema claro. Esto garantiza que el sitio funciona correctamente incluso si JavaScript falla.

Transiciones suaves entre temas

La transicion entre temas debe ser perceptible pero no lenta. Agregar una transicion global al cambiar de tema:

[data-theme] * {
  transition: background-color 0.3s ease, color 0.2s ease, border-color 0.3s ease;
}

Sin embargo, esta regla puede causar problemas de rendimiento porque aplica transicion a todos los elementos. Una alternativa mas controlada es aplicar la transicion solo durante el cambio:

function toggleThemeWithTransition() {
  document.documentElement.classList.add('theme-transitioning')

  const next = toggleTheme()

  // Remover la clase despues de que la transicion termine
  setTimeout(() => {
    document.documentElement.classList.remove('theme-transitioning')
  }, 350)

  return next
}
.theme-transitioning,
.theme-transitioning *,
.theme-transitioning *::before,
.theme-transitioning *::after {
  transition: background-color 0.3s ease,
              color 0.2s ease,
              border-color 0.3s ease,
              box-shadow 0.3s ease !important;
}

De esta forma, las transiciones solo estan activas durante el cambio de tema y no afectan el rendimiento del scroll o las animaciones normales.

Integracion con Tailwind CSS

Si usas Tailwind CSS v4, puedes mapear tus custom properties a tokens de Tailwind usando @theme:

/* En tu archivo CSS principal */
@import "tailwindcss";

@theme {
  --color-background: var(--color-background);
  --color-surface: var(--color-surface);
  --color-text: var(--color-text);
  --color-text-secondary: var(--color-text-secondary);
  --color-border: var(--color-border);
  --color-primary: var(--color-primary);
}

Ahora puedes usar las clases de Tailwind con tus colores tematicos:

<div class="bg-background text-text border border-border rounded-lg p-4">
  <h2 class="text-text">Titulo</h2>
  <p class="text-text-secondary">Descripcion</p>
  <button class="bg-primary text-white px-4 py-2 rounded">
    Accion
  </button>
</div>

Los colores se resuelven segun el tema activo sin necesidad del prefijo dark:. El markup queda limpio y el cambio de tema funciona automaticamente.

Contraste y accesibilidad

Un sistema de temas no sirve si los textos no son legibles. Las WCAG requieren un ratio de contraste minimo de 4.5:1 para texto normal y 3:1 para texto grande. Debes verificar que ambos temas cumplan estos requisitos.

Herramientas utiles para verificar contraste:

  • Chrome DevTools: en el panel de estilos, haz click en un color y veras el ratio de contraste
  • WebAIM Contrast Checker: herramienta web para verificar pares de colores

Un error frecuente es que el tema oscuro tenga texto demasiado gris sobre fondo oscuro. Un #888 sobre #1a1a1a tiene un ratio de solo 3.8:1, que no cumple con AA para texto normal. Subir a #a0a0a0 lo lleva a 5.3:1, suficiente para cumplir.

Otro aspecto de accesibilidad es el boton de toggle. Debe comunicar el estado actual y la accion que realizara:

'use client'

import { useState, useEffect } from 'react'

export function ThemeToggle() {
  const [theme, setTheme] = useState('light')

  useEffect(() => {
    const current = document.documentElement.getAttribute('data-theme')
      || 'light'
    setTheme(current)
  }, [])

  const toggle = () => {
    const next = theme === 'dark' ? 'light' : 'dark'
    document.documentElement.setAttribute('data-theme', next)
    localStorage.setItem('theme', next)
    setTheme(next)
  }

  return (
    <button
      onClick={toggle}
      aria-label={theme === 'dark'
        ? 'Cambiar a tema claro'
        : 'Cambiar a tema oscuro'}
      title={theme === 'dark'
        ? 'Cambiar a tema claro'
        : 'Cambiar a tema oscuro'}
    >
      {theme === 'dark' ? 'Claro' : 'Oscuro'}
    </button>
  )
}

Testeando ambos temas

Para no olvidar testear el tema oscuro, establece una rutina:

  1. Durante desarrollo: alterna frecuentemente entre temas. Un atajo de teclado ayuda (por ejemplo, Ctrl + Shift + D)
  2. En CSS nuevo: cada vez que agregues un color hardcodeado, preguntate si deberia ser una variable
  3. En revision de codigo: verifica que no haya colores hexadecimales directos que ignoren el tema
  4. Automatizado: puedes usar Playwright o Cypress para tomar capturas en ambos temas y compararlas visualmente

Un consejo practico: busca en tu CSS cualquier #, rgb( o hsl( que no este dentro de la definicion de las variables. Si encuentras un background: #fff en un componente, probablemente deberia ser background: var(--color-background) o var(--color-surface).

Conclusion

CSS custom properties ofrecen la forma mas limpia y eficiente de implementar temas. No requieren JavaScript en runtime, funcionan con cualquier framework, permiten transiciones suaves y se integran naturalmente con Tailwind CSS. La clave esta en tres puntos: nombres semanticos para las variables, un script bloqueante en el head para evitar el FOUC, y verificacion de contraste en ambos temas para mantener la accesibilidad.