todos os posts

Como construir um botão com variantes - entendendo o que copiamos e colamos.

ReactTailwind5 min de leitura

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