Componentes com Tailwind em React: cva e tailwind-merge
Como transformar 20 classes repetidas num componente React com variantes, sem que a className vinda por prop brigue com a classe padrão do botão.
Num projeto React, classes repetidas normalmente pedem um componente, não uma
classe CSS global. O componente reúne markup, estados e acessibilidade; clsx
monta condições, tailwind-merge resolve conflitos conhecidos e cva descreve
variantes com uma API explícita.
O botão da clínica já aparece em seis telas. Vamos começar simples e só adicionar bibliotecas quando um problema concreto surgir.
Extraia o contrato inteiro
O primeiro componente ainda pode usar uma string literal. Ele preserva o tipo do botão e aceita conteúdo.
export function Botao({ children, type = 'button' }) {
return (
<button type={type} className="rounded-lg bg-cyan-600 px-4 py-2 font-bold text-white">
{children}
</button>
);
}Não extraia só porque o atributo passou de uma linha. Extraia porque “botão de ação” virou um conceito do produto com estados e uso recorrente.
className externo cria uma regra de precedência
Concatenar permite extensão, mas pode produzir utilidades concorrentes:
export function Botao({ className, children }) {
return (
<button className={`rounded-lg bg-cyan-600 px-4 py-2 ${className ?? ''}`}>
{children}
</button>
);
}Uma chamada com px-2 agora contém px-4 px-2. A ordem no atributo não é um
contrato seguro de cascata; quem vence depende da ordem das regras geradas.
clsx resolve condição, não conflito Tailwind
clsx remove condicionais barulhentas e ignora valores falsos:
import { clsx } from 'clsx';
const classe = clsx(
'rounded-lg px-4 py-2',
carregando && 'cursor-wait opacity-70',
className,
);Ele não sabe que px-2 e px-4 disputam padding inline. A saída pode continuar
com as duas.
tailwind-merge conhece grupos conflitantes
Crie uma função cn que passa a lista por clsx e depois pelo merge:
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...entradas) => twMerge(clsx(entradas));Executei o conflito real no Node:
console.log(twMerge('p-2 p-8 text-sm text-lg'));O merge manteve a última decisão de cada grupo. Ele não executa CSS nem mede estilo no navegador; aplica conhecimento das utilidades Tailwind.
CVA transforma variantes em props declaradas
Quando tom e tamanho têm combinações conhecidas, descreva-as numa tabela de variantes.
import { cva } from 'class-variance-authority';
export const estiloBotao = cva('rounded font-bold', {
variants: {
tom: {
primario: 'bg-cyan-600 text-white',
perigo: 'bg-rose-600 text-white',
},
tamanho: {
sm: 'px-2 py-1',
lg: 'px-4 py-3',
},
},
defaultVariants: { tom: 'primario', tamanho: 'sm' },
});console.log(estiloBotao({ tom: 'perigo', tamanho: 'lg' }));Os nomes completos aparecem no arquivo fonte, então o scanner consegue gerar as classes. A API também impede que qualquer string de cor vire variante pública.
Monte o componente sem esconder HTML nativo
Passe as props de botão e preserve foco, disabled e ref quando necessário.
export function Botao({ tom, tamanho, className, children, ...props }) {
return (
<button
{...props}
className={cn(estiloBotao({ tom, tamanho }), className)}
>
{children}
</button>
);
}Não aceite uma prop as ilimitada sem definir semântica. Um link e um botão têm
comportamentos diferentes. Polimorfismo que troca tag pode criar um a sem
href ou botão sem tipo.
Classe construída em runtime continua invisível
Nem React, CVA ou merge mudam a detecção textual. Este componente quebra:
function Etiqueta({ cor }) {
return <span className={`bg-${cor}-600 text-white`}>Status</span>;
}O Tailwind não executa a prop. Mapeie cada valor aceito para strings completas:
const tons = {
sucesso: 'bg-emerald-600 text-white',
alerta: 'bg-amber-300 text-slate-950',
};
return <span className={tons[tom]}>{children}</span>;Se a cor realmente vem de banco e pode ser qualquer valor validado, use variável
CSS como bg-(--cor-status). O diagnóstico detalhado está em
classe Tailwind não funciona.
Variantes não devem explodir em produto cartesiano
Três tons, quatro tamanhos, três densidades e cinco estados já formam 180 combinações teóricas. Nem todas precisam virar prop. Estado nativo como disabled pode ser classe base; loading pode ser comportamento; largura pode pertencer ao container.
Use compoundVariants apenas quando a combinação tem regra própria e teste.
Se cada nova tela pede uma exceção, o componente virou framework interno e
precisa ser dividido.
@apply e componente resolvem camadas diferentes
@apply cria um seletor CSS; componente cria uma interface de uso com markup e
comportamento. Para o botão da clínica, React é a unidade correta porque loading,
ícone, tipo e acessibilidade viajam juntos. A
lição de @apply mostra os casos de integração
em que o seletor continua útil.
Erro reproduzido: classe dinâmica que não existe
p-dinamico não converte uma prop em spacing. O mesmo nome também falha quando
forçado por @apply; executei o caso com o CLI 4.3.3:
@import "tailwindcss";
.qa { @apply p-dinamico; }printf '@import "tailwindcss"; .qa { @apply p-dinamico; }\n' | npx @tailwindcss/cli@4.3.3 -i - -o /dev/nullO processo encerrou com código 1. Modele tamanhos permitidos num mapa de strings
completo, como sm: 'p-2' e lg: 'p-6', para que compilador e API compartilhem
um conjunto finito.
Missão com conflito deliberado
Crie Botao com tamanho padrão px-4, passe className="px-2" e registre a
string antes e depois de twMerge. Depois adicione duas variantes com CVA e
navegue por teclado nos estados normal e disabled usando as classes da
lição de foco. A missão termina quando a
precedência externa é deliberada e nenhuma prop monta um nome Tailwind em
pedaços.
Teste a API, não a sequência literal inteira
Uma asserção que compara a string completa quebra quando você reorganiza classes sem mudar comportamento. Prefira testar que a variante correta produz os grupos essenciais e que conflitos são resolvidos. Para o botão de perigo grande, verifique fundo de perigo, padding grande e atributos nativos.
Renderize também casos de borda: className ausente, override de padding, disabled, conteúdo com ícone e texto longo. Use uma ferramenta de acessibilidade para nome e foco, mas não confunda teste automático com navegação real por teclado. CVA organiza aparência; ele não garante semântica.
Se o projeto usa TypeScript, derive os tipos das variantes em vez de repetir uma união manual que pode divergir. Ainda mantenha props de negócio pequenas. “tom” e “tamanho” são compreensíveis; prop para cada utility transforma o componente numa folha CSS por JSON.
Observe o bundle JavaScript. tailwind-merge e CVA trabalham em runtime; num
componente server-only, parte desse custo pode ficar no servidor, mas num botão
cliente viaja ao navegador. Meça antes de instalar em toda camada. Para duas
condições simples, mapa de strings e clsx podem bastar.
Por último, documente exemplos válidos e inválidos. Mostre como sobrescrever e
qual decisão permanece protegida. O guia de Tailwind
encaixa essa API no sistema, enquanto a
lição de @apply ajuda a reconhecer quando o
problema era seletor de integração, não componente.
Entregue também um exemplo sem biblioteca, usando apenas mapa de strings. A comparação deixa claro qual complexidade CVA e merge realmente removeram e evita instalar abstração por hábito.
Guarde esse exemplo nos testes para que a próxima refatoração preserve a mesma regra de precedência e o mesmo comportamento de teclado.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em tailwind-merge 3.6.0, class-variance-authority 0.7.1 e @tailwindcss/cli 4.3.3 no Node 26.3.0, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Tailwind CSS — Detecting classes in source files — tailwindcss.com
- tailwind-merge — documentação oficial — github.com
- Class Variance Authority — documentação oficial — cva.style



