Modo escuro no Tailwind: comparação entre class e data-theme

Aquele dark:bg-gray-900 que pisca na tela levanta uma dúvida: afinal, no modo escuro do Tailwind, é melhor usar class ou data-theme?
Passei um bom tempo testando as duas abordagens. Sempre que pesquisava a documentação, encontrava explicações fragmentadas que, mesmo reunidas, pareciam incompletas. Então resolvi examinar a documentação oficial, discussões no GitHub e o código-fonte de algumas bibliotecas de componentes populares. Finalmente consegui organizar as ideias. Neste artigo, compartilho os problemas que encontrei e as escolhas que fizeram sentido ao longo do caminho.
As três estratégias de modo escuro do Tailwind
Antes de tudo, vale esclarecer: por padrão, o Tailwind oferece três estratégias de modo escuro, não apenas duas.
Estratégia media: seguir o sistema automaticamente
A estratégia media é a configuração padrão do Tailwind — e, para ser sincero, muita gente talvez nem saiba disso. Ela usa a media query CSS prefers-color-scheme para detectar automaticamente a preferência de modo escuro do sistema do usuário.
<!-- Nenhuma configuração é necessária; responde automaticamente ao sistema -->
<div class="bg-white dark:bg-gray-900">
O conteúdo muda automaticamente de acordo com a configuração do sistema
</div>
A vantagem é evidente: configuração zero e uma aparência alinhada à preferência do usuário sem que ele precise fazer nada. Mas a desvantagem também pesa: o usuário não pode escolher por conta própria. Quem deseja usar o modo escuro mesmo em um ambiente claro acaba tendo uma experiência menos satisfatória.
Estratégia class: controle manual da alternância
Na estratégia class, adicionamos a classe .dark a um elemento pai — normalmente o <html> — para ativar o modo escuro. Isso dá controle total ao desenvolvedor e permite implementar alternância manual e persistência da preferência do usuário.
<!-- Controle o nome da classe com JavaScript -->
<html class="dark">
<body class="bg-white dark:bg-gray-900">
O modo escuro está ativo
</body>
</html>
Hoje, essa é a abordagem mais usada. Há bastante documentação da comunidade, e a integração com diversas bibliotecas de terceiros costuma ser tranquila.
Estratégia data-theme: um seletor de atributo semântico
A estratégia data-theme usa o atributo data-theme="dark" em vez de um nome de classe. A semântica fica mais clara, e o suporte a vários temas surge de forma natural.
<html data-theme="dark">
<body class="bg-white dark:bg-gray-900">
O modo escuro está ativo
</body>
</html>
Também é muito simples expandir a solução para outros temas: você pode definir data-theme="oled" ou data-theme="sepia" como quiser. Isso é especialmente útil quando o projeto precisa oferecer vários modos de exibição.
Estratégia class em detalhes
Como funciona
O princípio da estratégia class é simples: quando a classe .dark está presente em algum elemento ancestral da árvore DOM, todos os estilos com o modificador dark:* passam a valer.
No Tailwind v3, a estratégia é habilitada no arquivo de configuração:
// tailwind.config.js
module.exports = {
darkMode: 'class',
// ...
}
O seletor CSS gerado tem esta estrutura:
.dark .dark:bg-gray-900 {
background-color: #111827;
}
O Tailwind v4 adotou uma nova configuração CSS-first, baseada na diretiva @custom-variant:
/* global.css */
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
Observe a pseudo-classe :where(): ela reduz a specificity a zero e, assim, não interfere no cálculo de prioridade dos demais estilos. É um detalhe importante.
Lógica de alternância com JavaScript
Um pequeno trecho de JavaScript é suficiente para permitir que o usuário alterne o tema:
// Obtém o tema atual
function getTheme() {
return localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
// Define o tema
function setTheme(theme) {
localStorage.setItem('theme', theme);
document.documentElement.classList.toggle('dark', theme === 'dark');
}
// Inicializa
setTheme(getTheme());
Esse código faz três coisas: lê a preferência do usuário no localStorage, segue o sistema quando não existe uma preferência e alterna o tema salvando a escolha. Para a maioria dos casos, basta.
Como evitar o flash branco
Também já encontrei o problema daquele breve flash branco durante o carregamento. A causa é simples: antes de o JavaScript ser executado, o HTML já foi renderizado no modo claro padrão.
A solução é colocar no <head> um script síncrono que defina o tema antes da renderização do DOM:
<head>
<script>
// Executa de forma síncrona para evitar o flash
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
</head>
Esse script precisa ser síncrono: não use defer nem async.
Vantagens e desvantagens
Vantagens:
- Implementação simples, intuitiva e rápida de aprender
- Muitos recursos da comunidade e soluções maduras para vários frameworks
- Boa integração com bibliotecas como next-themes
- Specificity ligeiramente maior, o que ajuda na sobreposição de estilos
Desvantagens:
- O nome de classe
.darknão é tão semântico: é preciso interpretar o código para entender que ele representa o modo escuro - A expansão para vários temas exige diferentes nomes de classe e pode ficar confusa
- A combinação com uma abordagem de variáveis CSS requer ajustes adicionais
Estratégia data-theme em detalhes
Como funciona
A base da estratégia data-theme é usar um seletor de atributo, e não um seletor de classe. No Tailwind v4, a configuração fica assim:
@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
O seletor CSS gerado:
[data-theme='dark'] .dark:bg-gray-900 {
background-color: #111827;
}
O Tailwind v3 também oferece suporte, mas exige uma configuração em array:
// tailwind.config.js
module.exports = {
darkMode: ['selector', '[data-theme="dark"]'],
}
Combinação com variáveis CSS
Na prática, a estratégia data-theme e as variáveis CSS formam uma combinação natural. É possível definir valores diferentes para as variáveis em cada data-theme:
/* globals.css */
:root {
--background: 0 0% 100%;
--foreground: 222 84% 5%;
}
[data-theme='dark'] {
--background: 222 84% 5%;
--foreground: 210 40% 98%;
}
[data-theme='oled'] {
--background: 0 0% 0%; /* Preto puro */
--foreground: 0 0% 100%;
}
Depois, basta referenciar essas variáveis na configuração do Tailwind:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
}
}
}
}
Ao trocar o atributo data-theme, todos os estilos que usam essas variáveis mudam automaticamente. Não é necessário escrever o modificador dark: em cada componente, o que torna o trabalho bem mais agradável.
A experiência prática do shadcn/ui
A biblioteca de componentes shadcn/ui usa por padrão a combinação data-theme + variáveis CSS. Ao examinar seus arquivos de estilo, encontramos várias definições como estas:
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* ... Mais variáveis */
}
.dark,
[data-theme='dark'] {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... Mais variáveis */
}
}
O interessante é que ela aceita tanto a classe .dark quanto o atributo [data-theme='dark'], justamente para acomodar diferentes hábitos. Se você usa shadcn/ui, pode escolher qualquer uma das duas formas para ativar o modo escuro.
Capacidade de expansão para vários temas
A maior vantagem de data-theme está aqui: o suporte a vários temas. Definir um modo OLED ou um modo de leitura é simples:
<html data-theme="oled">
<!-- Fundo preto puro, ideal para telas OLED -->
</html>
<html data-theme="sepia">
<!-- Fundo amarelado, ideal para leitura -->
</html>
A lógica de alternância só precisa mudar o valor do atributo:
function setTheme(theme) {
localStorage.setItem('theme', theme);
document.documentElement.dataset.theme = theme;
}
É difícil obter a mesma flexibilidade com a estratégia class.
Vantagens e desvantagens
Vantagens:
- Semântica clara:
data-theme="dark"identifica o modo escuro imediatamente - Suporte natural à expansão para vários temas
- Ótima combinação com uma abordagem de variáveis CSS
- Compatibilidade nativa com bibliotecas como shadcn/ui e daisyUI
Desvantagens:
- No Tailwind v3, é preciso configurar o selector manualmente
- Algumas bibliotecas de terceiros podem exigir adaptação
- Há menos documentação da comunidade, embora isso esteja melhorando
Matriz de comparação das duas abordagens
Organizei uma tabela com os principais critérios de comparação:
| Critério | Estratégia Class | Estratégia Data-theme |
|---|---|---|
| Complexidade de implementação | Baixa, configuração simples | Média, exige entender seletores de atributo |
| Clareza semântica | Média, o significado de .dark precisa ser interpretado | Alta, data-theme é intuitivo |
| Expansão para vários temas | Difícil, exige vários nomes de classe | Fácil, basta alterar o valor do atributo |
| Suporte da comunidade | Alto, documentação abundante | Médio, em expansão |
| Integração com variáveis CSS | Exige adaptação adicional | Natural e simples |
| Tailwind v3 | darkMode: 'class' | darkMode: ['selector', '...'] |
| Tailwind v4 | @custom-variant | @custom-variant |
| Compatibilidade com bibliotecas de terceiros | É preciso verificar a compatibilidade | Compatibilidade nativa com shadcn/ui e outras |
| Specificity | Ligeiramente maior (seletor de classe) | Igual (seletor de atributo) |
Quando escolher a estratégia Class?
- O projeto é simples e precisa apenas dos modos claro e escuro
- Você usa Next.js com next-themes
- A equipe conhece bem a configuração do Tailwind v3
- Você precisa consultar muitos exemplos da comunidade
Quando escolher a estratégia Data-theme?
- O projeto precisa oferecer vários temas, como OLED ou modo de leitura
- Você usa shadcn/ui ou uma biblioteca de componentes semelhante
- Você quer uma integração profunda com variáveis CSS
- O projeto exige mais clareza semântica
Integração prática com frameworks
Integração com Astro
A integração entre Astro e Tailwind é simples, mas há uma armadilha: o tratamento das View Transitions.
Configuração básica:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()]
}
});
Script do modo escuro:
<!-- Coloque no head de BaseLayout.astro -->
<script is:inline>
// Script síncrono para evitar o flash branco
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
// Ou use data-theme
// document.documentElement.dataset.theme = 'dark';
}
</script>
Tratamento das View Transitions:
As View Transitions do Astro renderizam o DOM novamente durante a navegação, então o estado do modo escuro pode se perder. É preciso escutar o evento astro:after-swap e reaplicar o tema:
<script>
document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});
</script>
Essa etapa é importante e fácil de esquecer. Eu também já caí nessa armadilha.
Integração entre Next.js e next-themes
Para projetos Next.js, recomendo a biblioteca next-themes. Ela encapsula toda a lógica de alternância, incluindo compatibilidade com SSR e tratamento de hydration.
Instalação:
npm install next-themes
Configuração do Provider:
// components/ThemeProvider.tsx
import { ThemeProvider } from 'next-themes';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class" // Usa a estratégia class
defaultTheme="system" // Segue o sistema por padrão
enableSystem={true} // Ativa a detecção do sistema
disableTransitionOnChange // Evita o flash durante a troca
>
{children}
</ThemeProvider>
);
}
Quer mudar para a estratégia data-theme? Basta alterar o atributo attribute:
<ThemeProvider attribute="data-theme" defaultTheme="system">
Uso no layout:
// app/layout.tsx
import { ThemeProvider } from './components/ThemeProvider';
export default function RootLayout({ children }) {
return (
<html lang="zh">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
Componente do botão de alternância:
// components/ThemeToggle.tsx
import { useTheme } from 'next-themes';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-lg"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
O next-themes cuida automaticamente da persistência no localStorage, da detecção da preferência do sistema e dos problemas de hydration. É uma solução prática.
Novidades do Tailwind v4
O Tailwind v4 introduziu uma nova configuração CSS-first, que também alterou a forma de configurar o modo escuro.
Diretiva @custom-variant
O variant que antes era definido em um arquivo de configuração JavaScript agora pode ser declarado diretamente no CSS:
@import 'tailwindcss';
/* Estratégia Class */
@custom-variant dark (&:where(.dark, .dark *));
/* Estratégia Data-theme */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
A vantagem é a clareza: não é preciso recompilar o JavaScript ao alterar a configuração.
Definição de variáveis com a diretiva @theme
Em conjunto com a estratégia data-theme, a diretiva @theme permite definir variáveis de tema:
@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
@theme {
--color-primary: oklch(0.65 0.2 150);
--color-muted: oklch(0.9 0.02 200);
}
/* Sobrescreve as variáveis no modo escuro */
[data-theme='dark'] {
--color-primary: oklch(0.7 0.15 180);
--color-muted: oklch(0.3 0.02 200);
}
Depois, essas cores podem ser usadas diretamente:
<button class="bg-primary text-white">Botão</button>
Após mudar o data-theme, as cores são atualizadas automaticamente, sem estilos repetitivos como dark:bg-primary-dark.
Alternância entre três estados
Para alternar entre light/dark/system, é preciso combinar a lógica com a API window.matchMedia:
function setTheme(theme) {
if (theme === 'system') {
localStorage.removeItem('theme');
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
} else {
localStorage.setItem('theme', theme);
document.documentElement.dataset.theme = theme;
}
}
// Escuta mudanças na preferência do sistema
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
}
});
Assim, o usuário pode escolher um tema fixo ou sempre acompanhar a preferência do sistema.
Resumo das boas práticas
Escolha recomendada
Para a maioria dos projetos, minha recomendação é:
- Projetos simples: use a estratégia class com um script básico de alternância
- Projetos com shadcn/ui: adote diretamente data-theme + variáveis CSS
- Projetos com vários temas: use obrigatoriamente a estratégia data-theme
- Projetos Next.js: use next-themes e escolha o attribute conforme a necessidade
- Projetos Astro: não se esqueça de tratar as View Transitions
Dicas práticas
Solução completa para evitar o flash branco:
<head>
<script is:inline>
// Script síncrono, executado antes da renderização
(function() {
const theme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && systemDark)) {
document.documentElement.classList.add('dark');
// Ou
document.documentElement.dataset.theme = 'dark';
}
})();
</script>
</head>
Como tratar projetos SSR:
Projetos SSR como Next.js precisam evitar hydration mismatch. O next-themes já resolve esse problema. Mas, se você quiser implementar a solução por conta própria, atenção ao seguinte:
// Usa useEffect para evitar incompatibilidade no SSR
import { useEffect, useState } from 'react';
function useTheme() {
const [theme, setTheme] = useState('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
setTheme(saved || 'light');
}, []);
return theme;
}
Nomes semânticos para variáveis CSS:
Use nomes semânticos para as variáveis, em vez de nomes de cores:
/* Recomendado */
:root {
--background: ...;
--foreground: ...;
--primary: ...;
--muted: ...;
}
/* Não recomendado */
:root {
--white: ...;
--black: ...;
--gray-900: ...;
}
Os nomes semânticos deixam a troca de tema mais intuitiva e facilitam a inclusão de novos temas no futuro.
Conclusão
Depois de tudo isso, a ideia central é simples: a estratégia class é madura e fácil de usar, sendo adequada para a maioria dos projetos; já data-theme oferece semântica mais clara e funciona melhor em cenários com vários temas ou forte integração com variáveis CSS.
A diretiva @custom-variant do Tailwind v4 tornou a configuração das duas abordagens mais simples e intuitiva. A escolha depende das necessidades do projeto: para quem usa shadcn/ui, data-theme é mais natural; para uma alternância simples entre claro e escuro, class continua sendo uma opção confiável.
Não deixe de cuidar dos detalhes da integração com frameworks, como as View Transitions do Astro e a hydration de SSR no Next.js. Se esses pontos não forem tratados, a experiência do usuário será prejudicada.
Referências
- Documentação oficial do modo escuro no Tailwind CSS
- Documentação de temas do shadcn/ui
- next-themes no GitHub
- Modo escuro com Tailwind no Astro
FAQ
Qual é a diferença entre @custom-variant no Tailwind v4 e a configuração da v3?
É possível usar class e data-theme ao mesmo tempo?
O código ficou muito extenso com tantos modificadores dark:. O que fazer?
Procedimento:
1. Defina as variáveis em globals.css com @theme
2. Sobrescreva os valores para cada [data-theme]
3. Referencie essas variáveis em tailwind.config.js
Assim, bg-primary se adapta automaticamente à troca de tema.
Como evitar que o estado do modo escuro seja perdido em um projeto Astro?
document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});
É uma etapa que muitos desenvolvedores deixam passar.
Como eliminar o flash branco durante o carregamento da página?
<script>
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
Importante: o script precisa ser síncrono; não use defer nem async.
12 min de leitura · Publicado em: 28 mar 2026 · Atualizado em: 4 set 2026
Tailwind e shadcn/ui na prática
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Layout responsivo com Tailwind na prática: container queries e estratégia de breakpoints
Um guia aprofundado sobre container queries e estratégias de breakpoints no Tailwind CSS, da responsividade baseada na viewport aos layouts responsivos no nível dos componentes.
Parte 6 de 14
Próximo
Padrões de composição no shadcn/ui: boas práticas para integrar vários componentes
Aprenda as melhores práticas de composição no shadcn/ui e domine combinações comuns como Dialog + Form e DataTable + DropdownMenu, além de tópicos avançados como Context, gerenciamento de estado e otimização de desempenho.
Parte 8 de 14



Comentários
Entre com GitHub para comentar