Tematización dinámica de color en React: más allá de las variables CSS
Embed This Widget
Add the script tag and a data attribute to embed this widget.
Embed via iframe for maximum compatibility.
<iframe src="https://colorfyi.com/iframe/entity//" width="420" height="400" frameborder="0" style="border:0;border-radius:10px;max-width:100%" loading="lazy"></iframe>
Paste this URL in WordPress, Medium, or any oEmbed-compatible platform.
https://colorfyi.com/entity//
Add a dynamic SVG badge to your README or docs.
[](https://colorfyi.com/entity//)
Use the native HTML custom element.
La mayoría de los tutoriales de tematización se detienen en el interruptor de modo oscuro. Cambias una clase en <html>, ajustas un puñado de variables CSS, listo. Eso cubre un caso común pero pasa por alto el problema más interesante: ¿qué pasa si los usuarios pueden elegir su propio color de marca? ¿Y si tu producto SaaS sirve a múltiples clientes, cada uno con su propia identidad? ¿Y si el tema necesita generar toda una paleta armoniosa a partir de un único valor hex en tiempo de ejecución?
Ahí es donde comienza la tematización dinámica de color —y las variables CSS solas no son suficientes. Necesitas algoritmos de color en tiempo de ejecución, estado de React que sobreviva la navegación, estrategias de persistencia y una arquitectura que no vuelva a renderizar todo el árbol de componentes cada vez que se mueve un control deslizante.
Esta guía cubre cada una de esas capas en profundidad.
Enfoque de variables CSS para temas en React
La base: arquitectura de tokens semánticos
Todo sistema de temas dinámicos comienza con la misma base: propiedades personalizadas CSS nombradas por su rol semántico, no por su valor de color. No nombres una variable --blue-500. Nómbrala --brand-primary. El valor puede cambiar; el rol no puede:
/* globals.css */
:root {
/* Tokens semánticos — estos son los que usan los componentes */
--bg-base: #F8FAFC;
--bg-surface: #FFFFFF;
--bg-sunken: #F1F5F9;
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-disabled: #94A3B8;
--brand-primary: #2563EB;
--brand-hover: #1D4ED8;
--brand-subtle: #EFF6FF;
--on-brand: #FFFFFF;
--border-default: #E2E8F0;
--border-strong: #CBD5E1;
--status-error: #DC2626;
--status-success: #16A34A;
--status-warning: #D97706;
}
Los componentes referencian tokens, nunca colores directos:
.card {
background: var(--bg-surface);
border: 1px solid var(--border-default);
color: var(--text-primary);
}
.btn-primary {
background: var(--brand-primary);
color: var(--on-brand);
}
.btn-primary:hover {
background: var(--brand-hover);
}
Cuando actualizas --brand-primary en JavaScript, cada componente que lo referencia se actualiza instantáneamente —sin re-renderizado de React, sin prop drilling, sin suscripciones a contexto. CSS maneja la cascada.
Escritura de tokens desde JavaScript
La API del DOM para establecer propiedades personalizadas es directa:
document.documentElement.style.setProperty('--brand-primary', '#7C3AED');
document.documentElement.style.setProperty('--brand-hover', '#6D28D9');
document.documentElement.style.setProperty('--brand-subtle', '#EDE9FE');
document.documentElement.style.setProperty('--on-brand', '#FFFFFF');
Leerlos de vuelta:
const brand = getComputedStyle(document.documentElement)
.getPropertyValue('--brand-primary')
.trim();
Esta es la base mecánica. La parte difícil es generar el conjunto completo de tokens a partir de un único hex de marca.
Generación de temas en tiempo de ejecución con algoritmos de color
El problema con los conjuntos de tokens manuales
Si los usuarios pueden elegir cualquier color —incluyendo tonos inusuales como #FF5733 o #0D9488— no puedes codificar de forma fija una variante oscura, un estado hover o un color de texto sobre la marca. Necesitas calcularlos a partir de la entrada.
Generación del conjunto completo de tokens
Aquí tienes una función completa que toma un hex de marca y produce todos los tokens que necesita tu tema:
import chroma from 'chroma-js';
function generateThemeTokens(brandHex) {
const brand = chroma(brandHex);
// Determinar si el texto sobre la marca debe ser negro o blanco
// Basado en la luminancia relativa WCAG
const brandLuminance = brand.luminance();
const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';
// Generar una escala desde tinte claro hasta sombra oscura en OKLCH
// para pasos perceptualmente uniformes
const scale = chroma.scale([
chroma(brandHex).brighten(2.5).desaturate(0.5).hex(),
brandHex,
chroma(brandHex).darken(2.5).hex(),
]).mode('oklch').colors(11);
return {
'--brand-primary': brandHex,
'--brand-hover': chroma(brandHex).darken(0.5).hex(),
'--brand-active': chroma(brandHex).darken(1).hex(),
'--brand-subtle': scale[1], // Tinte muy claro
'--brand-muted': scale[2], // Tinte claro
'--on-brand': onBrand,
// Primitivos de escala para acceso completo a la paleta
'--brand-50': scale[0],
'--brand-100': scale[1],
'--brand-200': scale[2],
'--brand-300': scale[3],
'--brand-400': scale[4],
'--brand-500': scale[5],
'--brand-600': scale[6],
'--brand-700': scale[7],
'--brand-800': scale[8],
'--brand-900': scale[9],
'--brand-950': scale[10],
};
}
function applyTheme(brandHex) {
const tokens = generateThemeTokens(brandHex);
const root = document.documentElement;
Object.entries(tokens).forEach(([prop, value]) => {
root.style.setProperty(prop, value);
});
}
Pares de colores accesibles
Cuando se generan temas a partir de entradas arbitrarias del usuario, la accesibilidad no puede asumirse. Un color elegido por el usuario puede producir un contraste deficiente contra el blanco. Necesitas verificar y ajustar:
function ensureContrast(foreground, background, minRatio = 4.5) {
let color = chroma(foreground);
let ratio = chroma.contrast(color.hex(), background);
// Si el contraste es insuficiente, oscurecer el primer plano hasta que pase
let iterations = 0;
while (ratio < minRatio && iterations < 20) {
color = color.darken(0.2);
ratio = chroma.contrast(color.hex(), background);
iterations++;
}
return color.hex();
}
// Para una etiqueta de texto con color de marca sobre fondo blanco
const safeLabel = ensureContrast('#FFD700', '#FFFFFF'); // Amarillo ajustado para cumplir AA
Aplica esto cuando generes tokens para elementos de texto que aparecen sobre blanco u otras superficies fijas.
Colores complementarios y de acento
El Generador de paletas muestra cómo un único color base produce una familia de armonía completa. Puedes replicar esa lógica en código usando la rotación de matiz:
function generateComplementaryAccent(brandHex) {
const hsl = chroma(brandHex).hsl();
// Rotar el matiz 180° para el complementario, ajustar la luminosidad para mantener el equilibrio
const complementHue = (hsl[0] + 180) % 360;
return chroma.hsl(complementHue, hsl[1] * 0.9, hsl[2]).hex();
}
function generateAnalogousAccent(brandHex, degrees = 30) {
const hsl = chroma(brandHex).hsl();
const analogousHue = (hsl[0] + degrees) % 360;
return chroma.hsl(analogousHue, hsl[1], hsl[2]).hex();
}
Contexto de React para la gestión del estado del tema
Arquitectura de ThemeContext
El contexto del tema sirve como puente entre las interacciones del usuario (elegir un color, activar el modo oscuro) y la aplicación de tokens a nivel del DOM. Debe ser delgado: almacena el estado, lo aplica al DOM y expone un setter. El cálculo de color ocurre fuera del contexto:
// contexts/ThemeContext.tsx
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from 'react';
import { generateThemeTokens } from '../lib/theme-generator';
interface ThemeState {
brandHex: string;
mode: 'light' | 'dark' | 'system';
}
interface ThemeContextValue extends ThemeState {
setBrand: (hex: string) => void;
setMode: (mode: 'light' | 'dark' | 'system') => void;
resolvedMode: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
const STORAGE_KEY = 'app-theme';
function getStoredTheme(): ThemeState {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored) as ThemeState;
} catch {}
return { brandHex: '#2563EB', mode: 'system' };
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ThemeState>(getStoredTheme);
const systemDark =
typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const resolvedMode: 'light' | 'dark' =
state.mode === 'system'
? systemDark ? 'dark' : 'light'
: state.mode;
// Aplicar tokens CSS cada vez que cambia el estado
useEffect(() => {
const tokens = generateThemeTokens(state.brandHex);
const root = document.documentElement;
Object.entries(tokens).forEach(([prop, value]) => {
root.style.setProperty(prop, value as string);
});
root.setAttribute('data-theme', resolvedMode);
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state, resolvedMode]);
// Reaccionar a los cambios de preferencia del sistema
useEffect(() => {
if (state.mode !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
document.documentElement.setAttribute(
'data-theme',
e.matches ? 'dark' : 'light',
);
};
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}, [state.mode]);
const setBrand = useCallback((hex: string) => {
setState(prev => ({ ...prev, brandHex: hex }));
}, []);
const setMode = useCallback((mode: 'light' | 'dark' | 'system') => {
setState(prev => ({ ...prev, mode }));
}, []);
return (
<ThemeContext.Provider value={{ ...state, setBrand, setMode, resolvedMode }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
El componente selector de color
Con el contexto en su lugar, un selector de color se convierte en una capa delgada de UI:
// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';
export function BrandColorPicker() {
const { brandHex, setBrand } = useTheme();
const [localHex, setLocalHex] = useState(brandHex);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setLocalHex(value);
// Aplicar debounce a la generación costosa de tokens
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
setBrand(value);
}
}, 150);
};
return (
<div className="flex items-center gap-3">
<label htmlFor="brand-picker" className="text-sm font-medium">
Color de marca
</label>
<div className="relative">
<input
id="brand-picker"
type="color"
value={localHex}
onChange={handleChange}
className="sr-only"
/>
<label
htmlFor="brand-picker"
className="block w-8 h-8 rounded-md cursor-pointer ring-2 ring-offset-2"
style={{
backgroundColor: localHex,
// el color del anillo usa el token de marca
'--tw-ring-color': 'var(--brand-primary)',
} as React.CSSProperties}
aria-label={`Color de marca actual: ${localHex}`}
/>
</div>
<input
type="text"
value={localHex}
onChange={handleChange}
className="w-24 px-2 py-1 text-sm font-mono border rounded"
placeholder="#2563EB"
aria-label="Valor hex del color de marca"
/>
</div>
);
}
El debounce de 150ms es fundamental —sin él, generateThemeTokens se ejecuta en cada pulsación de tecla al escribir en el campo hex, lo cual es innecesariamente costoso.
Clases de color dinámicas en Tailwind CSS
El desafío de Tailwind con colores en tiempo de ejecución
Tailwind genera clases de utilidad en tiempo de compilación. Clases como bg-brand-500 solo existen en tu hoja de estilos si el escáner de Tailwind las encontró en tus archivos fuente. Un color determinado en tiempo de ejecución —digamos, lo que el usuario acaba de elegir— no estará en la hoja de estilos.
Hay dos soluciones, y sirven para necesidades diferentes:
Solución 1: Puente de variables CSS
El enfoque más limpio es definir un pequeño conjunto de tokens del tema de Tailwind que apunten a propiedades personalizadas CSS y luego controlar los valores de esas propiedades en tiempo de ejecución:
/* styles.css (Tailwind v4) */
@import "tailwindcss";
@theme {
--color-brand: var(--brand-primary);
--color-brand-hover: var(--brand-hover);
--color-brand-subtle: var(--brand-subtle);
--color-on-brand: var(--on-brand);
}
Ahora bg-brand, text-brand y hover:bg-brand-hover son clases de Tailwind válidas que reflejan el valor que tenga --brand-primary en tiempo de ejecución:
<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
Acción principal
</button>
Cambiar --brand-primary vía JavaScript actualiza instantáneamente cada elemento con bg-brand —sin necesidad de re-renderizado.
Solución 2: Estilos en línea para colores dinámicos únicos
Cuando un componente necesita un color en tiempo de ejecución verdaderamente único (como una muestra de color en un visor de paletas), usa un estilo en línea. La sintaxis de valor arbitrario de Tailwind bg-[#FF5733] funciona para valores estáticos conocidos en tiempo de compilación, pero no para bg-[${dynamicHex}] dentro de un literal de plantilla en JSX —la clase no existirá en la hoja de estilos.
// Correcto para colores verdaderamente dinámicos:
<div
style={{ backgroundColor: swatch.hex }}
className="w-8 h-8 rounded"
aria-label={swatch.name}
/>
// Esto NO funcionará en tiempo de ejecución (clase no en la hoja de estilos):
<div className={`bg-[${swatch.hex}]`} /> // Incorrecto
Persistencia de las preferencias de color del usuario
localStorage para persistencia entre sesiones
El ThemeContext anterior ya escribe en localStorage en el useEffect. El patrón clave para compatibilidad con SSR es inicializar el estado de forma perezosa —pasando una función a useState que lee localStorage solo en el cliente, nunca durante el renderizado del servidor:
const [state, setState] = useState<ThemeState>(() => {
// Esta función solo se ejecuta en el cliente, después de la hidratación
if (typeof window === 'undefined') {
return { brandHex: '#2563EB', mode: 'system' };
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : { brandHex: '#2563EB', mode: 'system' };
} catch {
return { brandHex: '#2563EB', mode: 'system' };
}
});
Prevención del destello del tema predeterminado al cargar
Para aplicaciones con renderizado del lado del servidor (Next.js App Router), el árbol de componentes se renderiza en el servidor antes de llegar al navegador. Si inicializas con el tema predeterminado, los usuarios que hayan guardado un tema personalizado verán un destello —el azul predeterminado aparece momentáneamente antes de que cargue el morado persistido.
La solución es un script en línea bloqueante en el <head> que aplica el tema guardado antes de que el navegador renderice cualquier píxel:
// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var stored = JSON.parse(localStorage.getItem('app-theme') || '{}');
var brand = stored.brandHex || '#2563EB';
var mode = stored.mode || 'system';
var resolvedMode = mode === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: mode;
document.documentElement.setAttribute('data-theme', resolvedMode);
// Tokens mínimos en línea para prevenir el destello
document.documentElement.style.setProperty('--brand-primary', brand);
} catch(e) {}
})();
`,
}}
/>
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
El script en línea aplica solo los tokens más críticos (color de marca, atributo de modo de tema) de forma síncrona. El conjunto completo de tokens lo aplica el ThemeProvider después de la hidratación —pero como los tokens visuales dominantes ya son correctos, los usuarios no ven ningún destello.
Persistencia del lado del servidor mediante cookies
Para aplicaciones autenticadas, almacenar el tema en una cookie (legible por el servidor) permite el SSR con el tema correcto desde el primer byte:
// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const brandHex = request.cookies.get('brand-hex')?.value ?? '#2563EB';
const mode = request.cookies.get('theme-mode')?.value ?? 'system';
// Pasar el tema a la página mediante encabezados de respuesta o parámetros de búsqueda
const response = NextResponse.next();
response.headers.set('x-brand-hex', brandHex);
response.headers.set('x-theme-mode', mode);
return response;
}
Lee los encabezados en tu componente servidor layout.tsx raíz y pasa el tema inicial al ThemeProvider como prop. El proveedor se inicializa con el tema conocido por el servidor, eliminando el destello por completo.
Generación de variantes de modo oscuro accesibles
Cuando generas un tema claro a partir de un color de marca, también necesitas una versión en modo oscuro. El desafío: el mismo hex de marca que funciona bien sobre un fondo claro —digamos, #2563EB— a menudo falla los requisitos de contraste WCAG sobre superficies oscuras.
Un enfoque robusto genera dos conjuntos de tokens —uno para claro, uno para oscuro— a partir de la misma entrada de marca:
function generateDarkTokens(brandHex) {
const brand = chroma(brandHex);
const hsl = brand.hsl();
// Para el modo oscuro, aclarar la marca para garantizar contraste contra superficies oscuras
const darkBrand = chroma.hsl(hsl[0], hsl[1] * 0.9, Math.max(0.55, hsl[2] + 0.15));
return {
'[data-theme="dark"]': {
'--brand-primary': darkBrand.hex(),
'--brand-hover': darkBrand.lighten(0.3).hex(),
'--brand-subtle': darkBrand.darken(2).desaturate(0.5).hex(),
'--on-brand': '#000000', // A menudo negro sobre marca aclarada en modo oscuro
'--bg-base': '#0F172A',
'--bg-surface': '#1E293B',
'--text-primary': '#F1F5F9',
'--text-secondary': '#94A3B8',
},
};
}
Aplica los tokens oscuros mediante el patrón de atributo data-theme="dark". Cuando el ThemeProvider establece document.documentElement.setAttribute('data-theme', 'dark'), la cascada CSS aplica los valores de tokens del modo oscuro.
Para verificar que un par de colores generado cumple WCAG, usa el Generador de paletas para visualizar la escala completa y verificar las relaciones de contraste entre pasos.
Conclusiones clave
- Las propiedades personalizadas CSS son la base —los tokens semánticos nombrados por rol, no por valor de color, permiten cambios completos de tema sin modificar los componentes.
- La generación de tokens en tiempo de ejecución a partir de un único hex de marca requiere: una escala de luminosidad/oscuridad, verificación de contraste WCAG para el texto sobre la marca y cálculo separado para las variantes de modo oscuro.
- El contexto de React gestiona el estado del tema (hex de marca activo, modo claro/oscuro) y aplica los tokens al DOM como efecto secundario —los componentes nunca necesitan suscribirse al contexto del tema para aplicar colores.
- Aplica debounce a los cálculos de color costosos (150ms) cuando estén conectados a entradas continuas como selectores de color o controles deslizantes.
- Para Tailwind CSS: define un puente de variable CSS en
@themede modo que las utilidadesbg-brandreflejen el valor del token en tiempo de ejecución; usa estilos en línea solo para colores dinámicos verdaderamente únicos por elemento. - Previene el destello del tema predeterminado con un script bloqueante síncrono en
<head>que aplica los tokens más críticos antes del renderizado. - Para aplicaciones SSR con temas por usuario, almacena el tema en una cookie para que el servidor pueda renderizar el HTML inicial correcto.
- Usa el Generador de tonos para inspeccionar y validar las escalas de color generadas contra el rango completo de 50 a 950, y el Generador de paletas para verificar la armonía y las relaciones de contraste en toda la paleta del tema.
Colores relacionados
Marcas relacionadas
Herramientas relacionadas
Generador de paletas
Genera paletas de colores armoniosas usando esquemas complementarios, análogos, triádicos y complementarios divididos.
Generador de tonos
Genera escalas de tonos estilo Tailwind CSS (50–950) a partir de cualquier color base para sistemas de diseño.