Tutoriais

Tematização Dinâmica de Cores no React: Além das Variáveis CSS

11 min de leitura

A maioria dos tutoriais de tematização para no alternador de modo escuro. Troca uma classe no <html>, inverte algumas variáveis CSS e pronto. Isso cobre um caso comum, mas ignora o problema mais interessante: e se os usuários puderem escolher sua própria cor de marca? E se seu produto SaaS atender a múltiplos clientes, cada um com sua própria identidade? E se o tema precisar gerar uma paleta harmoniosa completa a partir de um único valor hex de entrada em tempo de execução?

É aí que começa a tematização dinâmica de cores — e as variáveis CSS sozinhas não são suficientes. Você precisa de algoritmos de cor em tempo de execução, estado React que sobreviva à navegação, estratégias de persistência e uma arquitetura que não re-renderize toda a sua árvore de componentes toda vez que um slider é movido.

Este guia aborda cada uma dessas camadas em profundidade.


Abordagem com Variáveis CSS para Temas React

A Base: Arquitetura de Token Semântico

Todo sistema de tema dinâmico começa com a mesma base: propriedades customizadas CSS nomeadas por seu papel semântico, não por seu valor de cor. Não nomeie uma variável --blue-500. Nomeie-a --brand-primary. O valor pode mudar; o papel não pode:

/* globals.css */
:root {
  /* Tokens semânticos — esses são os que os componentes usam */
  --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;
}

Os componentes referenciam tokens, nunca cores brutas:

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

Quando você atualiza --brand-primary em JavaScript, cada componente que o referencia é atualizado instantaneamente — sem re-renderização do React, sem prop drilling, sem assinaturas de contexto. O CSS lida com a cascata.

Escrevendo Tokens a partir do JavaScript

A API DOM para definir propriedades customizadas é simples:

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

Lendo-as de volta:

const brand = getComputedStyle(document.documentElement)
  .getPropertyValue('--brand-primary')
  .trim();

Essa é a base mecânica. A parte difícil é gerar o conjunto completo de tokens a partir de um único hex de marca.


Geração de Tema em Tempo de Execução com Algoritmos de Cor

O Problema com Conjuntos de Tokens Manuais

Se os usuários puderem escolher qualquer cor — incluindo cores incomuns como #FF5733 ou #0D9488 — você não pode codificar uma variante escura, um estado de hover ou uma cor de texto on-brand. Você precisa computá-los a partir da entrada.

Gerando o Conjunto Completo de Tokens

Aqui está uma função completa que recebe um hex de marca e produz todos os tokens que seu tema precisa:

import chroma from 'chroma-js';

function generateThemeTokens(brandHex) {
  const brand = chroma(brandHex);

  // Determinar se o texto on-brand deve ser preto ou branco
  // Com base na luminância relativa WCAG
  const brandLuminance = brand.luminance();
  const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';

  // Gerar uma escala de tonalidade clara a sombra escura em OKLCH
  // para passos 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],   // Tonalidade muito clara
    '--brand-muted': scale[2],    // Tonalidade clara
    '--on-brand': onBrand,

    // Primitivos de escala para acesso completo à 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 Cores Acessíveis

Ao gerar temas a partir de entradas arbitrárias do usuário, a acessibilidade não pode ser assumida. Uma cor escolhida pelo usuário pode produzir contraste ruim em relação ao branco. Você precisa verificar e ajustar:

function ensureContrast(foreground, background, minRatio = 4.5) {
  let color = chroma(foreground);
  let ratio = chroma.contrast(color.hex(), background);

  // Se o contraste for insuficiente, escurecer o primeiro plano até passar
  let iterations = 0;
  while (ratio < minRatio && iterations < 20) {
    color = color.darken(0.2);
    ratio = chroma.contrast(color.hex(), background);
    iterations++;
  }

  return color.hex();
}

// Para um rótulo de texto colorido com a marca em fundo branco
const safeLabel = ensureContrast('#FFD700', '#FFFFFF'); // Amarelo ajustado para passar AA

Aplique isso ao gerar tokens para elementos de texto que aparecem sobre branco ou outras superfícies fixas.

Cores Complementares e de Acento

O Gerador de Paleta mostra como uma única cor base produz uma família de harmonia completa. Você pode replicar essa lógica em código usando rotação de matiz:

function generateComplementaryAccent(brandHex) {
  const hsl = chroma(brandHex).hsl();
  // Rotacionar matiz em 180° para o complementar, ajustar luminosidade para manter equilíbrio
  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 React para Gerenciamento de Estado do Tema

Arquitetura do ThemeContext

O contexto de tema serve como ponte entre as interações do usuário (escolher uma cor, alternar o modo escuro) e a aplicação de tokens no nível do DOM. Ele deve ser enxuto: armazena o estado, aplica-o ao DOM e expõe um setter. A computação de cor acontece fora do 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 sempre que o estado mudar
  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]);

  // Reagir a mudanças de preferência do 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;
}

O Componente Seletor de Cor

Com o contexto em vigor, um seletor de cor se torna uma fina camada 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);

    // Debounce da geração de token custosa
    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">
        Cor da 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,
            // a cor do anel usa o token de marca
            '--tw-ring-color': 'var(--brand-primary)',
          } as React.CSSProperties}
          aria-label={`Cor atual da marca: ${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 da cor da marca"
      />
    </div>
  );
}

O debounce de 150ms é crítico — sem ele, generateThemeTokens é executado a cada tecla pressionada ao digitar no input hex, o que é desnecessariamente caro.


Classes de Cor Dinâmicas do Tailwind CSS

O Desafio do Tailwind com Cores em Tempo de Execução

O Tailwind gera classes utilitárias em tempo de build. Classes como bg-brand-500 só existem na sua folha de estilo se o scanner do Tailwind as encontrou em seus arquivos-fonte. Uma cor determinada em tempo de execução — digamos, o que o usuário acabou de escolher — não estará na folha de estilo.

Há duas soluções, e elas atendem a necessidades diferentes:

Solução 1: Ponte de Variáveis CSS

A abordagem mais limpa é definir um pequeno conjunto de tokens de tema do Tailwind que apontam para propriedades customizadas CSS, depois controlar os valores dessas propriedades em tempo de execução:

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

Agora bg-brand, text-brand e hover:bg-brand-hover são classes Tailwind válidas que refletem qualquer valor que --brand-primary tenha em tempo de execução:

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  Ação Primária
</button>

Alterar --brand-primary via JavaScript atualiza instantaneamente cada elemento com bg-brand — sem re-renderização necessária.

Solução 2: Estilos Inline para Cores Dinâmicas Únicas

Quando um componente precisa de uma cor em tempo de execução verdadeiramente única (como uma amostra de cor em um visualizador de paleta), use um estilo inline. A sintaxe de valor arbitrário bg-[#FF5733] do Tailwind funciona para valores estáticos conhecidos em tempo de build, mas não para bg-[${dynamicHex}] dentro de um template literal em JSX — a classe não existirá na folha de estilo.

// Correto para cores verdadeiramente dinâmicas:
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
  aria-label={swatch.name}
/>

// Isso NÃO funcionará em tempo de execução (classe não está na folha de estilo):
<div className={`bg-[${swatch.hex}]`} /> // Errado

Persistindo Preferências de Cor do Usuário

localStorage para Persistência entre Sessões

O ThemeContext acima já grava no localStorage no useEffect. O padrão chave para compatibilidade com SSR é inicializar o estado de forma lazy — passando uma função para useState que lê o localStorage apenas no cliente, nunca durante a renderização no servidor:

const [state, setState] = useState<ThemeState>(() => {
  // Esta função só é executada no cliente, após a hidratação
  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' };
  }
});

Prevenindo Flash do Tema Padrão no Carregamento

Para aplicativos renderizados no servidor (Next.js App Router), a árvore de componentes renderiza no servidor antes de chegar ao navegador. Se você inicializar com o tema padrão, usuários que salvaram um tema personalizado verão um flash — o azul padrão aparece momentaneamente antes que o roxo persistido seja carregado.

A solução é um script inline de bloqueio no <head> que aplica o tema salvo antes que o navegador renderize qualquer pixel:

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="pt-BR" 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 inline mínimos para prevenir flash
                  document.documentElement.style.setProperty('--brand-primary', brand);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

O script inline aplica apenas os tokens mais críticos (cor da marca, atributo de modo de tema) de forma síncrona. O conjunto completo de tokens é aplicado pelo ThemeProvider após a hidratação — mas como os tokens visuais dominantes já estão corretos, os usuários não veem nenhum flash.

Persistência no Servidor via Cookies

Para aplicativos autenticados, armazenar o tema em um cookie (legível pelo servidor) permite SSR com o tema correto desde o primeiro 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';

  // Passar o tema para a página via cabeçalhos de resposta ou parâmetros de busca
  const response = NextResponse.next();
  response.headers.set('x-brand-hex', brandHex);
  response.headers.set('x-theme-mode', mode);
  return response;
}

Leia os cabeçalhos no seu componente de servidor root layout.tsx e passe o tema inicial para o ThemeProvider como prop. O provedor inicializa com o tema conhecido pelo servidor, eliminando o flash completamente.


Gerando Variantes de Modo Escuro Acessíveis

Quando você gera um tema claro a partir de uma cor de marca, também precisa de uma versão do modo escuro. O desafio: o mesmo hex de marca que funciona bem em um fundo claro — digamos, #2563EB — frequentemente falha nos requisitos de contraste WCAG em superfícies escuras.

Uma abordagem robusta gera dois conjuntos de tokens — um para claro, um para escuro — a partir da mesma entrada de marca:

function generateDarkTokens(brandHex) {
  const brand = chroma(brandHex);
  const hsl = brand.hsl();

  // Para modo escuro, clarear a marca para garantir contraste em superfícies escuras
  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', // Frequentemente preto em marca clareada no modo escuro
      '--bg-base': '#0F172A',
      '--bg-surface': '#1E293B',
      '--text-primary': '#F1F5F9',
      '--text-secondary': '#94A3B8',
    },
  };
}

Aplique os tokens escuros via o padrão de atributo data-theme="dark". Quando o ThemeProvider define document.documentElement.setAttribute('data-theme', 'dark'), a cascata CSS aplica os valores de token do modo escuro.

Para verificar se um par de cores gerado passa no WCAG, use o Gerador de Paleta para visualizar a escala completa e verificar as proporções de contraste entre os passos.


Principais Conclusões

  • As propriedades customizadas CSS são a base — tokens semânticos nomeados por papel, não por valor de cor, permitem mudanças completas de tema sem modificar os componentes.
  • A geração de tokens em tempo de execução a partir de um único hex de marca requer: uma escala de claro/escuro, verificação de contraste WCAG para texto on-brand e computação separada para variantes de modo escuro.
  • O contexto React gerencia o estado do tema (hex de marca ativo, modo claro/escuro) e aplica tokens ao DOM como efeito colateral — os componentes nunca precisam assinar o contexto de tema para aplicar cores.
  • Faça debounce de cálculos de cor custosos (150ms) quando conectados a entradas contínuas como seletores de cor ou sliders.
  • Para Tailwind CSS: defina uma ponte de variáveis CSS em @theme para que as utilitárias bg-brand reflitam o valor do token em tempo de execução; use estilos inline apenas para cores dinâmicas verdadeiramente únicas por elemento.
  • Previna o flash do tema padrão com um script de bloqueio síncrono no <head> que aplica os tokens mais críticos antes da renderização.
  • Para aplicativos SSR com temas por usuário, armazene o tema em um cookie para que o servidor possa renderizar o HTML inicial correto.
  • Use o Gerador de Tonalidades para inspecionar e validar escalas de cores geradas em relação ao intervalo completo de 50 a 950, e o Gerador de Paleta para verificar harmonia e proporções de contraste em toda a paleta do tema.

Cores relacionadas

Marcas relacionadas

Ferramentas relacionadas