Índice
- O bug que aparece no primeiro deploy
- HTML com 40 classes: problema real ou estética?
- Componentização e CVA: o padrão que escala
@apply: o atalho que vira armadilha- tailwind-merge e clsx: a dupla que você precisa desde o dia zero
- tailwind.config: convenções antes que vire bagunça
- Tailwind v4: o que mudou de verdade
- Integração com design systems e tokens
- FAQ
- Checklist antes do primeiro deploy
Você configura o projeto, as classes funcionam no desenvolvimento, faz o deploy — e metade dos estilos sumiu em produção. Nenhum erro no console. Nenhum warning no build. O CSS simplesmente não tem as classes que você usou.
Esse é o primeiro encontro real que a maioria dos devs tem com Tailwind fora dos tutoriais. E ele não está sozinho: tem o @apply que recria exatamente o problema que você veio resolver, as classes dinâmicas que o compilador não encontra, o config que cresce sem controle, e o conflito de classes que produz bugs que levam horas para rastrear.
Este artigo cobre cada um desses problemas com código que reproduz e código que resolve.
O bug que aparece no primeiro deploy
O Tailwind não entrega todas as classes possíveis no bundle final — ele analisa estaticamente os arquivos que você configurou no content e inclui só as classes que encontrou. Em desenvolvimento, o JIT engine regenera o CSS em tempo real. Em produção, ele faz um scan único. Qualquer classe que não aparece como string literal nos arquivos mapeados é eliminada.
A configuração mínima que resolve 80% dos casos:
module.exports = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx}',
'./app/**/*.{js,ts,jsx,tsx}',
'./node_modules/@shadcn/ui/dist/**/*.{js,ts,jsx,tsx}',
],
}
A última linha é o que a maioria esquece. Se você usa shadcn/ui, Headless UI ou qualquer biblioteca de componentes que usa classes Tailwind internamente, o scan precisa incluir os arquivos dela. Sem isso, as classes que só aparecem dentro da lib somem no build.
O segundo motivo mais comum para classes sumirem é construção dinâmica de nomes:
const color = isError ? 'red' : 'green'
return <div className={`text-${color}-500`}>mensagem</div>
O compilador faz análise estática de strings. Ele não executa o código — ele procura por strings literais. text-red-500 e text-green-500 nunca aparecem como strings completas nesse código, então ambas são eliminadas.
const styles = {
error: 'text-red-500',
success: 'text-green-500',
}
return <div className={styles[isError ? 'error' : 'success']}>mensagem</div>
As strings completas precisam existir em algum lugar do código. Objeto de lookup, array de opções, variável com o nome completo — qualquer forma funciona, desde que o compilador consiga lê-la como texto estático.
⚠️ Atenção: Classes geradas por concatenação de string em runtime nunca entram no bundle de produção. Isso inclui variáveis de ambiente, dados da API, ou qualquer valor computado em tempo de execução. O compilador só encontra strings estáticas.
HTML com 40 classes: problema real ou estética?
O primeiro choque visual é inevitável:
<button className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2">
Salvar
</button>
A reação de "isso é ilegível" é natural. Mas ela está mirando no alvo errado.
O HTML com 40 classes não é o problema — é o sintoma de que aquele componente ainda não foi encapsulado. Em CSS tradicional, você esconderia a complexidade dentro de uma classe .btn-primary num arquivo externo. Com Tailwind, você esconde dentro do componente. A diferença é que no CSS o componente ainda não existe; com Tailwind, você é forçado a criá-lo.
O problema real aparece quando esse HTML fica inline em sete lugares diferentes do projeto, cada um com uma pequena variação de padding ou cor. Aí sim você tem uma dívida técnica — não por causa do Tailwind, mas por falta de componentização.
💡 Dica: A regra prática que funciona: se você copiou o mesmo bloco de classes mais de duas vezes, é um componente. Sem exceção, sem "depois eu refatoro".
Componentização e CVA: o padrão que escala
O padrão manual de objetos de variantes funciona para componentes simples:
const variants = {
primary: 'bg-emerald-500 text-white hover:bg-emerald-600',
secondary: 'bg-slate-700 text-slate-100 hover:bg-slate-600',
ghost: 'text-slate-300 hover:bg-slate-800 hover:text-white',
}
const sizes = {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
}
Conforme o sistema de componentes cresce — variantes compostas, estados disabled, combinações de variante + tamanho com estilo específico — esse padrão escala mal. É onde entra o class-variance-authority (CVA):
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-emerald-500 text-white hover:bg-emerald-600',
secondary: 'bg-slate-700 text-slate-100 hover:bg-slate-600',
ghost: 'text-slate-300 hover:bg-slate-800 hover:text-white',
destructive: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
},
},
compoundVariants: [
{
variant: 'ghost',
size: 'sm',
class: 'rounded-sm',
},
],
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
compoundVariants é o diferencial: você define estilos que só aplicam quando uma combinação específica de variantes está ativa. Sem esse recurso, você acaba com condicionais if variant === 'x' && size === 'y' espalhadas pelo componente.
Em produção, num componente de input com 12 combinações de variante, isso apareceu num PR em 2024 e o revisor perguntou: "onde está o tipo do variant?" — a ausência do VariantProps significava que qualquer string passava sem erro de TypeScript. CVA resolve isso automaticamente.
@apply: o atalho que vira armadilha
@apply existe para quem quer manter classes semânticas no HTML enquanto usa Tailwind por baixo. Em teoria, resolve o problema do HTML verboso. Na prática, recria o problema que o Tailwind veio resolver.
.btn-primary {
@apply bg-emerald-500 text-white px-4 py-2 rounded-md hover:bg-emerald-600;
}
Você acabou de criar um arquivo CSS com classes semânticas que dependem do Tailwind para funcionar — mas que ninguém vai refatorar quando o design mudar, porque a conexão entre .btn-primary e bg-emerald-500 está implícita, espalhada entre dois sistemas. Qualquer mudança de cor exige buscar em dois lugares.
| Cenário | Usar @apply? | Alternativa |
|---|---|---|
| Reset de tipografia global | ✓ Sim | — |
| Normalização de foco para acessibilidade | ✓ Sim | — |
| Componente de botão com variantes | ✗ Não | CVA ou objeto de variantes |
| Layout de card reutilizável | ✗ Não | Componente React/Vue |
| Classes que se repetem 2x no projeto | ✗ Não | Componente |
Classes base de @layer base | ✓ Sim | — |
A regra funciona assim: @apply é para o que não tem contexto de componente e não varia. Tipografia base, reset de elementos HTML, normalização global. Tudo que tem variante ou que pertence a uma UI específica deve ser componente.
@layer base {
h1, h2, h3 {
@apply font-semibold tracking-tight text-slate-100;
}
code:not([class]) {
@apply font-mono text-sm bg-slate-800 px-1 py-0.5 rounded;
}
::selection {
@apply bg-emerald-500/20 text-emerald-100;
}
}
Esse uso é aceitável porque não cria dependência de estado, não tem variante, e serve como normalização global que qualquer dev do time vai entender sem contexto.
tailwind-merge e clsx: a dupla que você precisa desde o dia zero
Tailwind não tem especificidade CSS por design — duas classes do mesmo grupo aplicadas ao mesmo elemento não produzem erro, mas o resultado depende da ordem em que aparecem na stylesheet gerada, não da ordem no className. Isso cria um bug que aparece em componentes com variantes:
function Card({ className, ...props }) {
return (
<div
className={`p-4 bg-slate-800 rounded-lg ${className}`}
{...props}
/>
)
}
<Card className="p-8" />
O resultado esperado é p-8. O resultado real depende de qual classe aparece primeiro na stylesheet compilada — o que muda entre builds. tailwind-merge resolve isso: ele detecta classes do mesmo grupo e mantém apenas a última declarada.
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
clsx resolve a composição condicional. twMerge resolve o conflito de classes do mesmo grupo. Juntos, cobrem os dois casos que aparecem em qualquer componente real:
<div
className={cn(
'p-4 bg-slate-800 rounded-lg',
isActive && 'bg-emerald-900',
isDisabled && 'opacity-50 cursor-not-allowed',
className
)}
/>
Se className vier com p-8, o twMerge garante que p-8 prevalece sobre p-4. Se isActive for true, bg-emerald-900 prevalece sobre bg-slate-800. Sem conflito, sem surpresa de especificidade.
Instale no dia zero. Não depois que o bug aparecer em produção.
npm install clsx tailwind-merge
tailwind.config: convenções antes que vire bagunça
O arquivo de config começa pequeno. Seis meses depois, tem 150 linhas, três paletas de cores que ninguém sabe se ainda estão em uso, e valores arbitrários hardcoded por todo o projeto.
O problema não é o config crescer — é crescer sem convenção. O erro mais comum: um dev adiciona text-[#3b82f6] porque "é só essa vez". Três meses depois, você tem 40 cores arbitrárias que não fazem parte da paleta oficial e que o design system desconhece.
Duas ferramentas que resolvem isso:
npm install -D eslint-plugin-tailwindcss prettier-plugin-tailwindcss
{
"plugins": ["tailwindcss"],
"rules": {
"tailwindcss/no-arbitrary-value": "warn",
"tailwindcss/classnames-order": "warn",
"tailwindcss/no-contradicting-classname": "error"
}
}
no-arbitrary-value gera warning para qualquer text-[#3b82f6] — forçando o dev a adicionar o token ao config ou justificar a exceção no PR. prettier-plugin-tailwindcss ordena automaticamente as classes segundo a ordem canônica do Tailwind, eliminando diffs gigantes por reordenação.
Conventions que funcionam na prática:
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
'brand-primary': 'var(--color-brand-primary)',
'brand-secondary': 'var(--color-brand-secondary)',
'surface-base': 'var(--color-surface-base)',
'surface-elevated': 'var(--color-surface-elevated)',
},
},
},
}
Usar variáveis CSS como valores dos tokens em vez de hex direto permite que o tema mude em runtime (dark mode, white-labeling) sem recompilar o Tailwind. É o padrão que shadcn/ui usa — por uma razão.
Tailwind v4: o que mudou de verdade
O v4 não é uma atualização incremental. É uma reescrita do engine com mudanças de API que quebram projetos existentes se você migrar sem ler o guia.
O que foi embora
tailwind.config.js não existe mais como arquivo JavaScript. A configuração agora vive no próprio CSS:
@import "tailwindcss";
@theme {
--color-brand-primary: #4ade80;
--color-brand-secondary: #06b6d4;
--font-sans: 'Inter', sans-serif;
--radius-lg: 0.75rem;
}
Qualquer variável definida em @theme vira automaticamente uma classe utilitária. --color-brand-primary gera text-brand-primary, bg-brand-primary, border-brand-primary. Sem configuração extra, sem extend.
O que mudou na sintaxe
/* v3 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* v4 */
@import "tailwindcss";
/* v3 — plugins com require() */
plugins: [require('@tailwindcss/typography')]
/* v4 — plugins como imports CSS */
@plugin "@tailwindcss/typography";
Variantes personalizadas
/* v4 — sem extend no JS, direto no CSS */
@variant hocus (&:hover, &:focus);
@variant supports-grid {
@supports (display: grid) {
@slot;
}
}
O engine Oxide
O v4 reescreveu o compilador em Rust (engine Oxide). O resultado em benchmarks internos da Tailwind Labs: build completo ~5x mais rápido, rebuild incremental ~100x mais rápido. Em projetos grandes com milhares de componentes, a diferença é perceptível.
Migrar agora? Se o projeto está em v3 e está funcionando: não. O ecossistema de plugins ainda está em processo de migração. Se você está iniciando um projeto hoje: v4 direto. Menos config, melhor performance, sintaxe mais coerente.
npm install tailwindcss@next @tailwindcss/vite@next
Integração com design systems e tokens
O problema clássico: o designer trabalha com tokens no Figma (color/brand/primary/500), o dev procura esse valor no config, não encontra, usa o hex hardcoded, e em seis meses os dois sistemas divergiram.
O fluxo que funciona com ferramentas reais:
Figma (tokens) → Tokens Studio → tokens.json → Style Dictionary → tailwind.config.js
Um exemplo mínimo com style-dictionary:
const StyleDictionary = require('style-dictionary')
StyleDictionary.extend({
source: ['tokens/**/*.json'],
platforms: {
tailwind: {
transformGroup: 'js',
buildPath: './',
files: [
{
destination: 'tailwind.tokens.js',
format: 'javascript/module',
filter: { attributes: { category: 'color' } },
},
],
},
},
}).buildAllPlatforms()
const tokens = require('./tailwind.tokens')
module.exports = {
theme: {
extend: {
colors: tokens.color,
},
},
}
Para times sem esse processo automatizado, a regra mínima que previne 90% da divergência: qualquer cor, espaçamento ou tipografia que aparece em mais de dois componentes no Figma entra no config como token nomeado semanticamente — não pelo valor (blue-500), mas pelo papel (interactive-primary, surface-muted, text-on-dark).
Nomes semânticos resistem a mudanças de paleta. bg-interactive-primary continua fazendo sentido quando a cor muda de verde para azul. bg-emerald-500 não.
FAQ
Classes Tailwind somem em produção mas funcionam em dev. O que está errado?
O JIT engine do desenvolvimento regenera o CSS a cada mudança. Em produção, faz um scan estático dos arquivos no content. As causas mais comuns: arquivo não mapeado no content, classe construída por concatenação de string (text-${color}-500), ou biblioteca de componentes não incluída no scan. Verifique os três antes de abrir issue.
@apply ainda tem lugar no Tailwind v4?
Sim, mas com escopo ainda mais restrito. No v4, a recomendação oficial é usar @apply apenas em @layer base para normalização de elementos HTML. Para componentes, o @layer components foi efetivamente substituído pelo padrão de componentes com CVA ou pelo @theme para tokens globais.
Como fazer dark mode sem flash (FOUC) no Next.js com App Router?
Com darkMode: 'class' no config v3 (ou equivalente no v4), você precisa definir a classe dark no <html> antes do primeiro render. No App Router, a solução mais robusta é um script inline no layout.tsx que lê o valor do cookie ou localStorage de forma síncrona, antes de qualquer hidratação:
export default function RootLayout({ children }) {
return (
<html suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{
__html: `
try {
const t = localStorage.getItem('theme') || 'system'
const d = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', d)
} catch {}
`
}} />
</head>
<body>{children}</body>
</html>
)
}
Tailwind funciona com CSS Modules?
Tecnicamente funciona. Praticamente, é uma escolha que você vai precisar justificar em qualquer code review. CSS Modules resolve escopo de classe — problema que o Tailwind resolve de forma diferente (classes utilitárias não têm conflito de escopo por design). Usar os dois juntos indica que o time ainda não decidiu qual abordagem quer. Escolha uma e documente.
Como lidar com bibliotecas que têm seus próprios estilos (MUI, Ant Design)?
Esse é o cenário onde Tailwind gera mais conflito. O CSS reset do Tailwind (preflight) remove estilos base de elementos HTML — o que quebra bibliotecas que dependem desses estilos. A solução menos destrutiva é desabilitar o preflight no config e aplicar apenas nas partes do projeto que são 100% Tailwind:
module.exports = {
corePlugins: {
preflight: false,
},
}
A solução mais robusta é isolar as partes do projeto: componentes de UI próprios usam Tailwind, componentes da biblioteca externa ficam como estão.
Qual a diferença real entre clsx e classnames?
clsx é um fork mais rápido e menor do classnames com API compatível. Para uso com Tailwind, os dois funcionam — clsx é preferido por ser ~30% menor e ter suporte a TypeScript melhor. O que importa é combinar com tailwind-merge; sem ele, os dois têm o mesmo problema de conflito de classes.
Checklist antes do primeiro deploy
Use antes de fazer o push para produção em qualquer projeto que usa Tailwind:
- O
contentdo config inclui todos os diretórios com arquivos que usam classes Tailwind? - Bibliotecas de componentes (shadcn/ui, Headless UI) estão mapeadas no
content? - Nenhuma classe é construída por concatenação de string em runtime?
eslint-plugin-tailwindcssestá instalado comno-arbitrary-valuecomowarn?prettier-plugin-tailwindcssestá no config do Prettier?- A função
cn()comclsx+tailwind-mergeestá criada e sendo usada? - Tokens de cor, tipografia e espaçamento do design estão no config (não hardcoded)?
- Dark mode foi testado sem flash em SSR (se aplicável)?
- Componentes com variantes usam CVA ou objeto de variantes explícito (não strings montadas)?
- O arquivo de config tem comentários indicando a origem de cada token customizado?
Com o setup correto, Tailwind em produção é previsível. Os problemas que aparecem depois — e aparecem — são quase sempre rastros de uma dessas dez checagens ignoradas no começo. O próximo passo natural depois de ter o Tailwind configurado é montar o design system de componentes: tokens, variantes, composição com CVA, e a integração com Storybook para documentar o sistema vivo.