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.
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.
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.
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.
Este es el problema mas comun al implementar temas con JavaScript. El flujo tipico es:
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.
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.
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.
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.
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:
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>
)
}
Para no olvidar testear el tema oscuro, establece una rutina:
Ctrl + Shift + D)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).
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.