Seis variáveis, dois temas
Como manter tema claro e escuro sem duplicar uma linha de estilo, usando variáveis CSS e o Tailwind 4.
2 min de leitura
A forma mais comum de fazer dark mode com Tailwind é escrever dark: em todo
lugar:
<div class="bg-white text-black dark:bg-neutral-950 dark:text-neutral-50">Funciona, e escala mal. Cada componente novo repete a decisão, e mudar a cor de fundo do site vira um find-and-replace em cinquenta arquivos.
A inversão
O truque é fazer o componente falar sobre papel, não sobre cor. Não existe "branco" nem "cinza-950" — existe "fundo".
:root {
--bg: #ffffff;
--surface: #fafafa;
--border: #e6e6e9;
--fg: #0a0a0a;
--muted: #6b6b73;
--accent: #ff5c39;
}
.dark {
--bg: #0a0a0a;
--surface: #111113;
--border: #232326;
--fg: #fafafa;
--muted: #8a8a93;
--accent: #ff5c39;
}Seis variáveis cobrem um site inteiro. Fundo, superfície elevada, borda, texto, texto secundário e um acento. Se você precisa de uma sétima, quase sempre é porque um componente está pedindo uma exceção que não deveria existir.
Ligando ao Tailwind 4
O Tailwind 4 dispensa tailwind.config.js — a configuração é CSS. O bloco @theme
transforma cada variável numa família de utilitários:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-line: var(--border);
--color-fg: var(--fg);
--color-muted: var(--muted);
--color-accent: var(--accent);
}Depois disso, bg-bg, text-muted e border-line existem como classes normais e
já respondem ao tema. O componente vira:
<div class="bg-bg text-fg border border-line">Sem um único dark:.
O @custom-variant na segunda linha é obrigatório: por padrão o Tailwind 4 usa
prefers-color-scheme, e um toggle manual precisa da estratégia de classe.
O flash na primeira pintura
Falta um problema real: o servidor não sabe qual tema o visitante escolheu. Se você renderizar claro e o usuário tiver escolhido escuro, ele vê um flash branco antes da hidratação.
O next-themes resolve injetando um script que roda antes da primeira pintura,
lendo o localStorage e aplicando a classe no <html>:
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange
>Duas consequências práticas. A primeira: o <html> precisa de
suppressHydrationWarning, porque o script muda o atributo antes do React comparar
as árvores. A segunda: qualquer componente que leia o tema — um botão que troca
entre sol e lua, por exemplo — só pode renderizar o ícone depois de montar:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return <button>{mounted ? (isDark ? <SunIcon /> : <MoonIcon />) : <span />}</button>;Parece exagero para um ícone, mas é exatamente o mesmo motivo: o servidor não tem como acertar esse chute.
O teste que importa
Um bom teste de tema não é abrir os dois e achar bonito. É este: escolha uma cor nova e troque-a em um lugar só. Se você precisar tocar em mais de um arquivo, os componentes ainda estão falando de cor em vez de papel.