Alternar tema

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

Easton editorial illustration: three-option fit selector

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 -->
&lt;div class="bg-white dark:bg-gray-900"&gt;
  O conteúdo muda automaticamente de acordo com a configuração do sistema
&lt;/div&gt;

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 &lt;html&gt; — 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 -->
&lt;html class="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    O modo escuro está ativo
  &lt;/body&gt;
&lt;/html&gt;

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.

&lt;html data-theme="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    O modo escuro está ativo
  &lt;/body&gt;
&lt;/html&gt;

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 &lt;head&gt; um script síncrono que defina o tema antes da renderização do DOM:

&lt;head&gt;
  &lt;script&gt;
    // 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');
    }
  &lt;/script&gt;
&lt;/head&gt;

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 .dark nã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:

&lt;html data-theme="oled"&gt;
  <!-- Fundo preto puro, ideal para telas OLED -->
&lt;/html&gt;

&lt;html data-theme="sepia"&gt;
  <!-- Fundo amarelado, ideal para leitura -->
&lt;/html&gt;

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:

Baixa
Complexidade da implementação com Class
Configuração simples
Média
Complexidade da implementação com Data-theme
Exige entender seletores de atributo
Alto
Suporte da comunidade para Class
Documentação abundante
Médio
Suporte da comunidade para Data-theme
Em expansão
Difícil
Expansão da Class para vários temas
Exige vários nomes de classe
Fácil
Expansão do Data-theme para vários temas
Basta alterar o valor do atributo
Source: Análise comparativa das abordagens
CritérioEstratégia ClassEstratégia Data-theme
Complexidade de implementaçãoBaixa, configuração simplesMédia, exige entender seletores de atributo
Clareza semânticaMédia, o significado de .dark precisa ser interpretadoAlta, data-theme é intuitivo
Expansão para vários temasDifícil, exige vários nomes de classeFácil, basta alterar o valor do atributo
Suporte da comunidadeAlto, documentação abundanteMédio, em expansão
Integração com variáveis CSSExige adaptação adicionalNatural e simples
Tailwind v3darkMode: 'class'darkMode: ['selector', '...']
Tailwind v4@custom-variant@custom-variant
Compatibilidade com bibliotecas de terceirosÉ preciso verificar a compatibilidadeCompatibilidade nativa com shadcn/ui e outras
SpecificityLigeiramente 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:

&lt;!-- Coloque no head de BaseLayout.astro --&gt;
&lt;script is:inline&gt;
  // 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';
  }
&lt;/script&gt;

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:

&lt;script&gt;
  document.addEventListener('astro:after-swap', () => {
    const theme = localStorage.getItem('theme');
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    }
  });
&lt;/script&gt;

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 (
    &lt;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
    &gt;
      {children}
    &lt;/ThemeProvider&gt;
  );
}

Quer mudar para a estratégia data-theme? Basta alterar o atributo attribute:

&lt;ThemeProvider attribute="data-theme" defaultTheme="system"&gt;

Uso no layout:

// app/layout.tsx
import { ThemeProvider } from './components/ThemeProvider';

export default function RootLayout({ children }) {
  return (
    &lt;html lang="zh"&gt;
      &lt;body&gt;
        &lt;ThemeProvider&gt;
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}

Componente do botão de alternância:

// components/ThemeToggle.tsx
import { useTheme } from 'next-themes';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-lg"
    &gt;
      {theme === 'dark' ? '☀️' : '🌙'}
    &lt;/button&gt;
  );
}

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:

&lt;button class="bg-primary text-white"&gt;Botão&lt;/button&gt;

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 é:

  1. Projetos simples: use a estratégia class com um script básico de alternância
  2. Projetos com shadcn/ui: adote diretamente data-theme + variáveis CSS
  3. Projetos com vários temas: use obrigatoriamente a estratégia data-theme
  4. Projetos Next.js: use next-themes e escolha o attribute conforme a necessidade
  5. Projetos Astro: não se esqueça de tratar as View Transitions

Dicas práticas

Solução completa para evitar o flash branco:

&lt;head&gt;
  &lt;script is:inline&gt;
    // 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';
      }
    })();
  &lt;/script&gt;
&lt;/head&gt;

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

FAQ

Qual é a diferença entre @custom-variant no Tailwind v4 e a configuração da v3?
A principal diferença está no local da configuração. Na v3, ela é definida no arquivo de configuração JavaScript (tailwind.config.js); na v4, é declarada no arquivo CSS com a diretiva @custom-variant. A funcionalidade é a mesma, mas a abordagem da v4 combina melhor com a filosofia de design CSS-first.
É possível usar class e data-theme ao mesmo tempo?
Sim, mas não é necessário. As duas opções oferecem a mesma funcionalidade, e usá-las simultaneamente só aumenta a complexidade. O shadcn/ui aceita tanto a classe .dark quanto o atributo [data-theme="dark"] para respeitar as preferências de diferentes usuários; basta escolher uma das opções.
O código ficou muito extenso com tantos modificadores dark:. O que fazer?
Use uma abordagem com variáveis CSS. Depois de definir as variáveis, basta alternar seus valores para atualizar automaticamente todos os estilos que as utilizam, sem escrever o modificador dark: em cada elemento.

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?
As View Transitions do Astro renderizam o DOM novamente durante a navegação e podem fazer o estado do modo escuro se perder. Para resolver, escute o evento astro:after-swap e reaplique o tema:

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?
Coloque no &lt;head&gt; um script síncrono que defina o tema antes de o DOM ser renderizado:

&lt;script&gt;
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
&lt;/script&gt;

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

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog