Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

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.

Rodolfo Mori4 min de leitura

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.

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

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

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

js
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export const cn = (...entradas) => twMerge(clsx(entradas));

Executei o conflito real no Node:

js
console.log(twMerge('p-2 p-8 text-sm text-lg'));
p-8 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.

js
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' },
});
js
console.log(estiloBotao({ tom: 'perigo', tamanho: 'lg' }));
rounded font-bold bg-rose-600 text-white px-4 py-3

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.

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

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

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

css
@import "tailwindcss";
.qa { @apply p-dinamico; }
bash
printf '@import "tailwindcss"; .qa { @apply p-dinamico; }\n' | npx @tailwindcss/cli@4.3.3 -i - -o /dev/null
Error: Cannot apply unknown utility class `p-dinamico`

O 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.

Ver todos os vídeos do canal
  • tailwind
  • react
  • componentes
  • cva
  • tailwind-merge
  • clsx

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. Tailwind CSS — Detecting classes in source files — tailwindcss.com
  2. tailwind-merge — documentação oficial — github.com
  3. Class Variance Authority — documentação oficial — cva.style

Continue por aqui