Como construir um botão com variantes - entendendo o que copiamos e colamos.
Heads up — this one's in Portuguese. Your browser's translate button will get you most of the way.

O shadcn/ui mudou a forma como a gente constrói interfaces atualmente. Em vez de instalar uma lib fechada, você copia e cola o código do componente direto no seu projeto e pronto, é só ser feliz. O Button já tem variantes, tamanhos, estados de foco, tudo funcionando redondinho.
Mas isso trouxe um lado negativo, ao meu ver: muita gente copia esses componentes sem entender o que está copiando. O cva(...) no topo do arquivo, o cn() envolvendo as classes, a tipagem das variantes — pra boa parte dos devs, é só um bloco de código que funciona, e se funciona, não se mexe. O maior problema é que, no dia em que algo quebra ou surge uma demanda de alteração, esse "não se mexe" vira um "ixi, e agora?"
Neste post eu vou construir, do zero, um Button bem simples com variantes e tamanhos — não pra reinventar o shadcn, muito pelo contrário: mostrar por que ele é tão incrível e explicar o que acontece por trás dos panos. A ideia é que, depois de ler, o próximo componente que você colar no seu projeto pare de ser só mais CTRL+C / CTRL+V e se torne algo que você saiba manter.
O que estamos construindo
Um Button com:
- Variantes:
primary,secondary,outline - Tamanhos:
sm,md,lgO uso final vai ficar assim:
<Button variant="outline" size="sm">
Salvar
</Button>
Simples de usar e de desenvolver. Aqui estamos utilizando o Button, mas isso se aplica a todos os outros componentes.
A base: Tailwind + tipagem das variantes
O passo mais natural de quem começa com Tailwind é escrever a className inteira toda vez que usa o componente. Funciona, mas logo você começa a sofrer a dor: isso não escala. Cada tela acaba com uma combinação de classes diferente, e não existe uma fonte única pra "o que é um botão primário" no seu projeto.
O primeiro passo é isolar isso em tipos:
type ButtonVariant = "primary" | "secondary" | "outline";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
Com isso, o TypeScript já barra variant="destructive" se essa opção não existir. O erro aparece na IDE, não em produção.
Juntando os pedaços (classes): o papel do cva e do cn()
Aqui normalmente é onde o texto colado do shadcn/ui começa a parecer "mágico" pra quem não entende o que está acontecendo.
cva (class-variance-authority) resolve um problema específico: ele mapeia combinações de props para strings de classe, sem você precisar escrever um switch gigante toda vez. Dá pra fazer na mão também, e eu acho válido entender as duas formas:
// Na mão, com um objeto de mapeamento
const variantClasses: Record<ButtonVariant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
outline: "border border-gray-300 text-gray-900 hover:bg-gray-50",
};
// Com cva
import { cva } from "class-variance-authority";
const buttonVariants = cva("rounded-md font-medium transition-colors", {
variants: {
variant: {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
outline: "border border-gray-300 text-gray-900 hover:bg-gray-50",
},
size: {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
},
},
defaultVariants: { variant: "primary", size: "md" },
});
cva só organiza essa lógica de um jeito declarativo e mais fácil de manter, mas o resultado final é o mesmo: uma função que recebe as propriedades definidas no variants e retorna a string de classes correspondente.
O outro nome que aparece em quase todo componente copiado é cn(), geralmente uma combinação de clsx (junta classes condicionalmente) com tailwind-merge (resolve conflitos entre classes do Tailwind). Isso importa porque, sem ele, se alguém passar className="bg-red-500" ao usar seu Button, essa classe pode simplesmente perder pra bg-blue-600 do variant, ou pior, os dois estilos brigarem de forma imprevisível. O tailwind-merge garante que a classe que foi passada por último vença, ou seja, a className que foi passada via props.
import { cn } from "@/lib/utils"; // clsx + tailwind-merge
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
Essa é a parte que costuma passar batido: sem cn(), seu componente parece flexível, mas na prática qualquer className externa vira um sorteio de qual estilo vai prevalecer.
Resultado final e o componente completo
import { cva, VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva("rounded-md font-medium transition-colors", {
variants: {
variant: {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
outline: "border border-gray-300 text-gray-900 hover:bg-gray-50",
},
size: {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
},
},
defaultVariants: { variant: "primary", size: "md" },
});
interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
// Lá no início do post, nós tínhamos declarado os tipos ButtonVariant e ButtonSize,
// mas agora que estamos usando cva, podemos inferir esses
// tipos diretamente do buttonVariants.
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
Uso:
<Button variant="outline" size="sm">
Salvar
</Button>
Por que vale a pena entender isso
Copiar um componente do shadcn/ui continua sendo uma ótima forma de ganhar velocidade — isso não muda —, e eu particularmente amo utilizá-lo: é uma ótima ferramenta para desenvolver MVPs e validar ideias. O que muda é o que você faz quando algo não sai como esperado: uma classe que não aplica (e se você já mexeu com CSS, você sabe que isso acontece com mais frequência do que gostaríamos 🤦♂️), e um className externo que some sem motivo aparente.
Quem entende o que cva e cn() fazem por trás resolve esse tipo de problema rapidinho. Quem não entende fica preso, tentando adivinhar, mexendo em tudo que é className. Na próxima vez que um componente colado "parecer errado" no seu projeto, o primeiro lugar pra olhar é justamente esse: como as classes estão sendo combinadas, e se alguma está perdendo a briga pra outra.