Botão
Botão com oito variantes (primário, secundário, terciário, contorno, fantasma, perigo, perigo suave e link), quatro tamanhos, formato arredondado, ícones, versão só com ícone, largura total e estado de carregamento. Com href vira link. Inclui Grupo de Botões (bordas coladas) e exporta classesDoBotao para dar o visual a outros elementos.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Botao>
Botão com oito variantes (primário, secundário, terciário, contorno, fantasma, perigo, perigo suave e link), quatro tamanhos, formato arredondado, versão só com ícone e estado de carregamento. Com `href` vira link. Quando só há ícone, passe `aria-label`.
Também aceita os 287 atributos nativos do elemento HTML (onClick, aria-*, disabled...), repassados ao elemento.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| variante | "perigo" | "primario" | "secundario" | "terciario" | "contorno" | "fantasma" | "perigo-suave" | "link" | — | |
| tamanho | "sm" | "md" | "xs" | "lg" | — | |
| larguraTotal | boolean | — | |
| soIcone | boolean | — | |
| arredondado | boolean | — | Cantos totalmente redondos (formato de pílula). |
| href | string | — | Com `href`, vira um link (`<a>`) com o visual de botão. |
| novaAba | boolean | — | Abre o link em nova aba (com rel="noopener noreferrer"). Só vale com `href`. |
| type | "button" | "submit" | "reset" | "button" | Tipo do botão. Padrão "button", para não enviar formulários sem querer. |
| icone | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Ícone antes do texto. |
| iconeFinal | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Ícone depois do texto. |
| carregando | boolean | false | Mostra um indicador de carga no lugar do ícone e ignora cliques, mas mantém o foco. |
| textoCarregando | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Texto no lugar de `children` enquanto carrega ("Enviando..."). |
<GrupoDeBotoes>
Junta botões lado a lado (ou um sobre o outro) com as bordas coladas e só as pontas arredondadas. Funciona melhor com `variante="contorno"` ou "secundario".
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| orientacao | "horizontal" | "vertical" | "horizontal" | |
| rotulo | string | — | Nome do grupo para leitores de tela ("Formatação do texto"). |
| childrenobrigatória | ReactNode | — |
Código
stories.tsx tem os exemplos usados no preview e no playground.
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, MouseEvent, ReactNode } from "react";
const VARIANTES = {
primario: "border-transparent bg-accent text-page hover:bg-accent/90",
secundario: "border-transparent bg-ink/10 text-ink hover:bg-ink/15",
terciario: "border-transparent bg-accent/10 text-accent hover:bg-accent/20",
contorno: "border-rule-strong text-ink hover:bg-ink/5",
fantasma: "border-transparent text-ink hover:bg-ink/10",
perigo: "border-transparent bg-critical text-page hover:bg-critical/90",
"perigo-suave": "border-transparent bg-critical/10 text-critical hover:bg-critical/20",
link: "border-transparent text-accent underline-offset-4 hover:underline",
} as const;
const TAMANHOS = {
xs: { caixa: "h-7 px-2.5 text-xs", quadrado: "size-7" },
sm: { caixa: "h-8 px-3 text-xs", quadrado: "size-8" },
md: { caixa: "h-10 px-4 text-sm", quadrado: "size-10" },
lg: { caixa: "h-12 px-6 text-base", quadrado: "size-12" },
} as const;
export type VarianteDoBotao = keyof typeof VARIANTES;
export type TamanhoDoBotao = keyof typeof TAMANHOS;
type OpcoesDeEstilo = {
variante?: VarianteDoBotao;
tamanho?: TamanhoDoBotao;
larguraTotal?: boolean;
soIcone?: boolean;
/** Cantos totalmente redondos (formato de pílula). */
arredondado?: boolean;
};
/**
* Classes do botão, para dar o mesmo visual a outro elemento (um `<Link>` do roteador,
* por exemplo): `<Link className={classesDoBotao({ variante: "contorno" })}>`.
*/
export function classesDoBotao({
variante = "primario",
tamanho = "md",
larguraTotal = false,
soIcone = false,
arredondado = false,
}: OpcoesDeEstilo = {}) {
// O link é só texto: sem altura fixa nem espaçamento (a não ser que seja só ícone)
const medidas = soIcone ? TAMANHOS[tamanho].quadrado : variante === "link" ? "text-sm" : TAMANHOS[tamanho].caixa;
return [
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 border font-medium whitespace-nowrap select-none",
"outline-accent transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 motion-safe:active:scale-[0.97]",
"disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-busy:cursor-progress",
"[&_svg]:size-4 [&_svg]:shrink-0",
arredondado ? "rounded-full" : "rounded-lg",
VARIANTES[variante],
medidas,
larguraTotal ? "w-full" : "",
]
.filter(Boolean)
.join(" ");
}
function Girando() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" className="motion-safe:animate-spin">
<circle cx="12" cy="12" r="9" opacity={0.25} />
<path d="M21 12a9 9 0 0 0-9-9" />
</svg>
);
}
export type BotaoProps = OpcoesDeEstilo &
Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> & {
/** Com `href`, vira um link (`<a>`) com o visual de botão. */
href?: string;
/** Abre o link em nova aba (com rel="noopener noreferrer"). Só vale com `href`. */
novaAba?: boolean;
/** Tipo do botão. Padrão "button", para não enviar formulários sem querer. */
type?: "button" | "submit" | "reset";
/** Ícone antes do texto. */
icone?: ReactNode;
/** Ícone depois do texto. */
iconeFinal?: ReactNode;
/** Mostra um indicador de carga no lugar do ícone e ignora cliques, mas mantém o foco. */
carregando?: boolean;
/** Texto no lugar de `children` enquanto carrega ("Enviando..."). */
textoCarregando?: ReactNode;
};
/**
* Botão com oito variantes (primário, secundário, terciário, contorno, fantasma, perigo,
* perigo suave e link), quatro tamanhos, formato arredondado, versão só com ícone e estado de carregamento. Com `href`
* vira link. Quando só há ícone, passe `aria-label`.
*/
export function Botao({
variante,
tamanho,
larguraTotal,
soIcone,
arredondado,
href,
novaAba,
type = "button",
icone,
iconeFinal,
carregando = false,
textoCarregando,
disabled,
onClick,
className = "",
children,
...resto
}: BotaoProps) {
const classes = `${classesDoBotao({ variante, tamanho, larguraTotal, soIcone, arredondado })} ${className}`;
const conteudo = soIcone ? (
// Só ícone: o ícone pode vir em `icone` ou em `children`
carregando ? <Girando /> : (icone ?? children)
) : (
<>
{carregando ? <Girando /> : icone}
{carregando && textoCarregando !== undefined ? textoCarregando : children}
{carregando ? null : iconeFinal}
</>
);
// Carregando: não envia nem clica, mas o botão continua focável (sem "sumir" para o teclado)
const aoClicar = (evento: MouseEvent<HTMLButtonElement>) => {
if (carregando) {
evento.preventDefault();
return;
}
onClick?.(evento);
};
if (href !== undefined) {
return (
<a
{...(resto as AnchorHTMLAttributes<HTMLAnchorElement>)}
href={disabled ? undefined : href}
target={novaAba ? "_blank" : undefined}
rel={novaAba ? "noopener noreferrer" : undefined}
aria-disabled={disabled || undefined}
aria-busy={carregando || undefined}
onClick={aoClicar as unknown as AnchorHTMLAttributes<HTMLAnchorElement>["onClick"]}
className={classes}
>
{conteudo}
</a>
);
}
return (
<button {...resto} type={type} disabled={disabled} aria-busy={carregando || undefined} onClick={aoClicar} className={classes}>
{conteudo}
</button>
);
}
import type { ReactNode } from "react";
/**
* Junta botões lado a lado (ou um sobre o outro) com as bordas coladas e só as pontas
* arredondadas. Funciona melhor com `variante="contorno"` ou "secundario".
*/
export function GrupoDeBotoes({
orientacao = "horizontal",
rotulo,
children,
}: {
orientacao?: "horizontal" | "vertical";
/** Nome do grupo para leitores de tela ("Formatação do texto"). */
rotulo?: string;
children: ReactNode;
}) {
const classes =
orientacao === "vertical"
? "inline-flex flex-col [&>*:not(:first-child)]:-mt-px [&>*:not(:first-child)]:rounded-t-none [&>*:not(:last-child)]:rounded-b-none"
: "inline-flex [&>*:not(:first-child)]:-ms-px [&>*:not(:first-child)]:rounded-s-none [&>*:not(:last-child)]:rounded-e-none";
return (
<div role="group" aria-label={rotulo} className={classes}>
{children}
</div>
);
}
import { useState, type ReactNode } from "react";
import { defineStories } from "@/registry/stories";
import { GrupoDeBotoes } from "./GrupoDeBotoes";
import { Botao, classesDoBotao, type TamanhoDoBotao, type VarianteDoBotao } from "./Botao";
function Icone({ children }: { children: ReactNode }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
}
const Mais = () => (
<Icone>
<path d="M12 5v14M5 12h14" />
</Icone>
);
const Email = () => (
<Icone>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</Icone>
);
const Lixeira = () => (
<Icone>
<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" />
</Icone>
);
const Engrenagem = () => (
<Icone>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M4.9 4.9 7 7M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1" />
</Icone>
);
const Reticencias = () => (
<Icone>
<circle cx="5" cy="12" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
</Icone>
);
const Voltar = () => (
<Icone>
<path d="m15 18-6-6 6-6" />
</Icone>
);
const Avancar = () => (
<Icone>
<path d="m9 18 6-6-6-6" />
</Icone>
);
const Negrito = () => (
<Icone>
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />
</Icone>
);
const Italico = () => (
<Icone>
<path d="M19 4h-9M14 20H5M15 4 9 20" />
</Icone>
);
const Sublinhado = () => (
<Icone>
<path d="M7 4v7a5 5 0 0 0 10 0V4M5 20h14" />
</Icone>
);
const Clipe = () => (
<Icone>
<path d="m21 11-9 9a5 5 0 0 1-7-7l9-9a3.5 3.5 0 0 1 5 5l-9 9a2 2 0 0 1-3-3l8-8" />
</Icone>
);
const VARIANTES: { variante: VarianteDoBotao; rotulo: string }[] = [
{ variante: "primario", rotulo: "Primário" },
{ variante: "secundario", rotulo: "Secundário" },
{ variante: "terciario", rotulo: "Terciário" },
{ variante: "contorno", rotulo: "Contorno" },
{ variante: "fantasma", rotulo: "Fantasma" },
{ variante: "perigo", rotulo: "Perigo" },
{ variante: "perigo-suave", rotulo: "Perigo suave" },
{ variante: "link", rotulo: "Link" },
];
/** Clica e fica "Enviando..." por dois segundos, como num envio de verdade. */
function EnvioDeArquivo() {
const [enviando, setEnviando] = useState(false);
return (
<Botao
carregando={enviando}
icone={<Clipe />}
textoCarregando="Enviando..."
onClick={() => {
setEnviando(true);
setTimeout(() => setEnviando(false), 2000);
}}
>
Enviar arquivo
</Botao>
);
}
export default defineStories({
args: {
texto: "Salvar alterações",
variante: "primario",
tamanho: "md",
comIcone: false,
soIcone: false,
arredondado: false,
carregando: false,
desabilitado: false,
larguraTotal: false,
},
controls: {
texto: { type: "text", label: "children" },
variante: { type: "select", options: VARIANTES.map((v) => v.variante) },
tamanho: { type: "select", options: ["xs", "sm", "md", "lg"] },
comIcone: { type: "boolean", label: "icone" },
soIcone: { type: "boolean", label: "soIcone (o texto vira aria-label)" },
arredondado: { type: "boolean" },
carregando: { type: "boolean" },
desabilitado: { type: "boolean", label: "disabled" },
larguraTotal: { type: "boolean" },
},
render: (a) => (
<div className="w-full max-w-xs">
<div className={a.larguraTotal ? "" : "flex justify-center"}>
<Botao
variante={a.variante as VarianteDoBotao}
tamanho={a.tamanho as TamanhoDoBotao}
soIcone={a.soIcone}
arredondado={a.arredondado}
icone={a.comIcone || a.soIcone ? <Mais /> : undefined}
carregando={a.carregando}
disabled={a.desabilitado}
larguraTotal={a.larguraTotal}
aria-label={a.soIcone ? a.texto : undefined}
>
{a.soIcone ? null : a.texto}
</Botao>
</div>
</div>
),
stories: [
{ name: "Botão" },
{
name: "Variantes",
render: () => (
<div className="flex flex-wrap gap-3">
{VARIANTES.map((v) => (
<Botao key={v.variante} variante={v.variante}>
{v.rotulo}
</Botao>
))}
</div>
),
},
{
name: "Tamanhos",
render: () => (
<div className="flex items-center gap-3">
<Botao tamanho="xs">Extra pequeno</Botao>
<Botao tamanho="sm">Pequeno</Botao>
<Botao tamanho="md">Médio</Botao>
<Botao tamanho="lg">Grande</Botao>
</div>
),
},
{
name: "Com ícones",
render: () => (
<div className="flex flex-wrap gap-3">
<Botao icone={<Mais />}>Adicionar membro</Botao>
<Botao variante="secundario" icone={<Email />}>
E-mail
</Botao>
<Botao variante="contorno" iconeFinal={<Email />}>
Enviar
</Botao>
<Botao variante="perigo" icone={<Lixeira />}>
Excluir
</Botao>
</div>
),
},
{
name: "Só ícone",
render: () => (
<div className="flex items-center gap-3">
<Botao soIcone variante="terciario" aria-label="Mais opções" icone={<Reticencias />} />
<Botao soIcone variante="secundario" aria-label="Configurações" icone={<Engrenagem />} />
<Botao soIcone variante="perigo" aria-label="Excluir" icone={<Lixeira />} />
<Botao soIcone tamanho="sm" variante="fantasma" aria-label="Adicionar" icone={<Mais />} />
<Botao soIcone tamanho="lg" variante="contorno" aria-label="Adicionar" icone={<Mais />} />
</div>
),
},
{
name: "Arredondado",
render: () => (
<div className="flex items-center gap-3">
<Botao arredondado iconeFinal={<Avancar />}>
Começar agora
</Botao>
<Botao arredondado variante="contorno">
Saiba mais
</Botao>
<Botao arredondado soIcone variante="secundario" aria-label="Adicionar" icone={<Mais />} />
</div>
),
},
{ name: "Carregando (clique)", render: () => <EnvioDeArquivo /> },
{
name: "Grupo de botões",
render: () => (
<div className="flex flex-col items-start gap-5">
<GrupoDeBotoes rotulo="Navegação">
<Botao variante="contorno" icone={<Voltar />}>
Anterior
</Botao>
<Botao variante="contorno" iconeFinal={<Avancar />}>
Próximo
</Botao>
</GrupoDeBotoes>
<GrupoDeBotoes rotulo="Formatação do texto">
<Botao soIcone variante="contorno" aria-label="Negrito" icone={<Negrito />} />
<Botao soIcone variante="contorno" aria-label="Itálico" icone={<Italico />} />
<Botao soIcone variante="contorno" aria-label="Sublinhado" icone={<Sublinhado />} />
</GrupoDeBotoes>
<GrupoDeBotoes orientacao="vertical" rotulo="Ordenação">
<Botao variante="secundario">Mais recentes</Botao>
<Botao variante="secundario">Mais antigos</Botao>
<Botao variante="secundario">Nome (A–Z)</Botao>
</GrupoDeBotoes>
</div>
),
},
{
name: "Desabilitados",
render: () => (
<div className="flex flex-wrap gap-3">
{VARIANTES.slice(0, 6).map((v) => (
<Botao key={v.variante} variante={v.variante} disabled>
{v.rotulo}
</Botao>
))}
</div>
),
},
{
name: "Largura total",
render: () => (
<div className="w-80 space-y-3">
<Botao larguraTotal>Continuar</Botao>
<Botao larguraTotal variante="contorno" icone={<Mais />}>
Criar projeto
</Botao>
</div>
),
},
{
name: "Como link",
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Botao href="#documentacao">Documentação</Botao>
<Botao href="https://example.com" novaAba variante="contorno">
Abrir em nova aba
</Botao>
{/* Qualquer elemento pode ganhar o visual do botão */}
<a href="#classes" className={classesDoBotao({ variante: "terciario" })}>
Link com classesDoBotao
</a>
</div>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}