Componentes
1. Botão
1.1 Papel#
Dispara uma ação. Não navega entre páginas (isso é link/âncora — pode parecer botão via variante, mas semanticamente <a>). Um grupo de ações tem um único primário; o resto desce na hierarquia.
1.2 Anatomia#
┌───────────────────────────────────────┐
│ [C] container │
│ ┌────┐ ┌─────────────────┐ │
│ │ Íc.│ │ Rótulo │ │
│ └────┘ └─────────────────┘ │
│ (A) (B) │
└───────────────────────────────────────┘
| Parte | Elemento | Regras |
|---|---|---|
| A | Ícone (opcional) | 16px (sm), 20px (md/lg); à esquerda do rótulo por padrão (à direita só para direção: "Avançar →"); gap sp-2 (8px); herda a cor do texto |
| B | Rótulo | Montserrat SemiBold (600); sentence case; nunca some no loading |
| C | Container | Radius --seed-radius-md (8px); padding horizontal por tamanho; borda 1px (transparente exceto outline) |
1.3 Variantes (5 + link)#
| Variante | Quando usar | Fundo / texto (light) |
|---|---|---|
| primary · ALTA | A ação principal da tela/seção — máx. 1 por contexto | FILL SÓLIDO: action-primary (verde institucional, 4.60:1) / text-on-brand |
| secondary · MÉDIA | Ações relevantes não-principais; par do primário | TINT + BORDA DE MARCA: fundo action-secondary-bg (turquesa-50) + borda turquesa-600 (4.60:1 — é ela que identifica o controle) / texto turquesa-800 (8.73:1) |
| outline · MÉDIA-BAIXA | Ações neutras/utilitárias (cancelar, voltar, exportar) | BORDA NEUTRA: transparente + borda cinza-600 (4.74:1) / text-primary |
| ghost · BAIXA | Ações terciárias, barras de ferramenta, densidade (>3 ações no grupo → use ghost) | SÓ TEXTO: transparente / turquesa-700 (6.32:1); hover action-ghost-hover |
| destructive | Ações irreversíveis (excluir, cancelar contrato) | action-destructive / branco |
| link | Ação inline em texto corrido | transparente / text-link, sublinhado no hover; padding zero |
A escada de mecanismos (por que as variantes não se parecem): cada nível de ênfase muda o MECANISMO visual — alta = preenchimento sólido · média = tint + borda de marca · média-baixa = só borda neutra · baixa = só texto. Diferença de mecanismo sobrevive ao aperto de olhos e à escala de cinza; diferença só de tom, não (foi o defeito da v0.1, corrigido aqui).
Regras de grupo (padrão Carbon): 1 alta ênfase por vista; grupos combinam 1 primário + N botões IGUAIS de ênfase menor (nunca primary+secondary+outline misturados no mesmo grupo); com mais de 3 ações, desça para ghost. Nunca destructive como primário "de layout" (ele é vermelho porque destrói, não porque é importante).
1.4 Tamanhos#
| Tamanho | Altura | Padding H | Fonte | Uso |
|---|---|---|---|---|
sm |
32px | 12px | 12px | Interfaces densas desktop (tabelas, toolbars) — não usar como CTA mobile |
full-width (comportamento, v0.7) |
herda lg/md | — | — | Em viewport de toque, CTA único de formulário/fluxo (enviar, confirmar, avançar) estica à largura do container (respeitando safe-areas). Padrão de app/checkout. Nunca para ações secundárias lado a lado |
md (padrão) |
44px | 20px | 14px | Tudo. Já nasce com alvo de toque WCAG |
lg |
52px | 28px | 16px | Hero, CTA de landing, fim de formulário longo |
Icon-only: quadrado (44×44 no md), aria-label obrigatório.
1.5 Estados (os 6 obrigatórios)#
| Estado | Primary (light) | Regra geral |
|---|---|---|
| default | action-primary |
— |
| hover | action-primary-hover (turquesa-700) |
escurece 1 stop; transição 100ms |
| active | action-primary-active (turquesa-800) |
escurece 2 stops; sem "pulo" de layout |
| focus-visible | + --seed-focus-ring |
anel 2px branco + 2px turquesa; visível em QUALQUER variante |
| disabled | surface-sunken + text-disabled |
cursor: not-allowed; sem hover; manter contraste legível do rótulo (o usuário precisa ler o que está indisponível) |
| loading | fundo do estado default + spinner 16px | spinner À ESQUERDA, rótulo permanece (sem layout shift, sem perder contexto); aria-busy="true"; cliques ignorados |
| selected (só em toggles) | fill sólido turquesa-800 + texto/ícone branco + troca de ícone | estado persistente de alternância — ver §1.5.1; aria-pressed="true" |
1.5.1 Botões de alternância (toggle) — sinais de estado (v0.4, alinhada à spec Material 3)#
Botão que liga/desliga algo (mão levantada, filtro ativo, favorito, negrito no editor) tem o estado selected. O estado precisa ser identificável DE RELANCE, no próprio botão, sem interação — texto visível NÃO é requisito (ícone puro é a norma em toolbars).
Sinais visuais no próprio botão (os que resolvem "está ligado?" sem tocar):
- Placa do container: desligado = transparente ou contorno sutil; ligado = placa sólida (turquesa-800, 8.13:1). Mecanismo, não tom.
- Miolo do glifo: desligado = ícone em CONTORNO; ligado = o MESMO ícone PREENCHIDO. Mesma silhueta sempre — a mão continua a mesma mão. Proibido: trocar por ícone diferente, ou usar rasura/corte como indicador de seleção (regra literal do Material 3: outline para não-selecionado, filled para selecionado).
- Forma do container (opcional, recomendado em toolbars com vários toggles): desligado = círculo; ligado = quadrado arredondado (radius-md). Terceiro sinal 100% visual — sobrevive em escala de cinza e para daltônicos (padrão M3 Expressive).
Sinal textual (complementar, nunca o principal): tooltip no hover / long-press no mobile, descrevendo a AÇÃO ("Abaixar a mão") — o visual mostra o estado, o texto diz o que o clique faz. Label visível só quando o layout já é de botão com texto.
Acessibilidade: aria-pressed="true|false" + aria-label sempre (ícone puro sem nome acessível é invisível pra leitor de tela).
Exceção semântica — negação de mídia (microfone, câmera, som): aqui o ícone cortado é CORRETO e esperado, porque a barra comunica o estado da mídia ("som desligado"), não a seleção do botão. Padrão: ativo = glifo normal em placa neutra; desativado = glifo cortado em placa danger sólida (vermelho-600) — dois sinais fortes, convenção universal de apps de chamada. Para estados de constrangimento real (mudo em reunião), somar indicador fora do botão.
Toggle é ferramenta; configuração é Switch. Preferência que persiste (notificações on/off) usa Switch (Bloco 2); botão-toggle é para ações de sessão e ferramentas.
1.6 Tokens de componente (camada 3 — nasce aqui)#
Definidos apenas onde o semântico não basta:
--seed-button-radius: var(--seed-radius-md);
--seed-button-font-weight: var(--seed-fw-semibold);
--seed-button-height-sm: 32px;
--seed-button-height-md: 44px;
--seed-button-height-lg: 52px;
--seed-button-gap: var(--seed-sp-2);
/* containers acessíveis (>=3:1) — a camada 3 existindo pra isso.
REGRA (aprendida na correção v0.5): token de componente que referencia
primitivo DEVE declarar o par dark — primitivo não troca de modo sozinho. */
--seed-button-secondary-border: var(--seed-turquesa-600);
--seed-button-secondary-text: var(--seed-turquesa-800);
--seed-button-outline-border: var(--seed-cinza-600);
--seed-button-ghost-text: var(--seed-turquesa-700);
--seed-button-toggle-on-bg: var(--seed-turquesa-800);
--seed-button-toggle-on-fg: #FFFFFF;
[data-theme="dark"] {
--seed-button-secondary-border: var(--seed-turquesa-300); /* 9.32:1 */
--seed-button-secondary-text: var(--seed-turquesa-100); /* 10.64:1 no tint dark */
--seed-button-outline-border: var(--seed-cinza-400); /* 6.74:1 */
--seed-button-ghost-text: var(--seed-turquesa-300); /* 9.32:1 — era 2.7 com o primitivo fixo */
--seed-button-toggle-on-bg: var(--seed-turquesa-300);
--seed-button-toggle-on-fg: var(--seed-turquesa-900); /* 7.39:1 */
}
1.7 Microcopy — regras de rótulo (tom SEED)#
- Verbo + objeto, sentence case: "Solicitar diagnóstico", "Enviar proposta", "Baixar relatório". Nunca "CLIQUE AQUI", nunca "Submeter".
- 1 a 3 palavras. Se precisa de mais, o problema é o contexto, não o botão.
- Destrutivo nomeia a consequência: "Excluir proposta" (não "Confirmar", não "Sim"). Em diálogo de confirmação, o par é "Cancelar" + "Excluir proposta".
- Loading mantém o rótulo — sem trocar para "Aguarde..." (o spinner já diz isso).
- Sem exclamação, sem urgência falsa ("Aproveite JÁ!") — o tom SEED é direto e sincero.
- Exemplos calibrados: ✅ "Ver proposta" · "Falar com engenheiro" · "Calcular economia" | ❌ "Saiba mais!!" · "Clique e descubra" · "OK".
1.8 Acessibilidade#
<button type="button|submit"> real (nunca div clicável) · foco visível sempre · aria-busy no loading · aria-label em icon-only · contraste do rótulo AA em todas as variantes/estados (auditado nos tokens: primary 4.60:1, destructive 5.19:1) · não comunicar estado só por cor (disabled também muda cursor e remove hover; loading tem spinner).
1.9 Código — HTML/CSS (peças, site, e-mails ricos*)#
*em e-mail real, botão é tabela — ver sistema de e-mail (Fase 4).
<button class="seed-btn seed-btn--primary seed-btn--md" type="button">
Solicitar diagnóstico
</button>
<button class="seed-btn seed-btn--primary seed-btn--md" type="button" aria-busy="true" disabled>
<span class="seed-btn__spinner" aria-hidden="true"></span> Enviar proposta
</button>
.seed-btn{
display:inline-flex;align-items:center;justify-content:center;gap:var(--seed-button-gap, var(--seed-sp-2));
font-family:var(--seed-font-sans);font-weight:var(--seed-fw-semibold);
border:1px solid transparent;border-radius:var(--seed-radius-md);cursor:pointer;
transition:background var(--seed-dur-productive-fast) var(--seed-ease-productive),
border-color var(--seed-dur-productive-fast) var(--seed-ease-productive);
}
.seed-btn:focus-visible{outline:none;box-shadow:var(--seed-focus-ring)}
.seed-btn--sm{height:32px;padding:0 12px;font-size:12px}
.seed-btn--md{height:44px;padding:0 20px;font-size:14px}
.seed-btn--lg{height:52px;padding:0 28px;font-size:16px}
.seed-btn--primary{background:var(--seed-action-primary);color:var(--seed-text-on-brand)}
.seed-btn--primary:hover:not(:disabled){background:var(--seed-action-primary-hover)}
.seed-btn--primary:active:not(:disabled){background:var(--seed-action-primary-active)}
.seed-btn--secondary{background:var(--seed-action-secondary-bg);
border-color:var(--seed-button-secondary-border);color:var(--seed-button-secondary-text)}
.seed-btn--secondary:hover:not(:disabled){filter:brightness(.96)}
.seed-btn--outline{background:transparent;border-color:var(--seed-button-outline-border);color:var(--seed-text-primary)}
.seed-btn--outline:hover:not(:disabled){background:var(--seed-action-ghost-hover)}
.seed-btn--ghost{background:transparent;color:var(--seed-button-ghost-text)}
.seed-btn--ghost:hover:not(:disabled){background:var(--seed-action-ghost-hover)}
.seed-btn--destructive{background:var(--seed-action-destructive);color:#fff}
.seed-btn--destructive:hover:not(:disabled){background:var(--seed-action-destructive-hover)}
.seed-btn:disabled{background:var(--seed-surface-sunken);border-color:transparent;
color:var(--seed-text-disabled);cursor:not-allowed}
.seed-btn__spinner{width:16px;height:16px;border-radius:50%;
border:2px solid currentColor;border-top-color:transparent;
animation:seed-spin .7s linear infinite}
@keyframes seed-spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion: reduce){.seed-btn__spinner{animation-duration:1.4s}}
1.10 Código — React/TypeScript (produtos: ERP, chat, site Lovable)#
Padrão shadcn/cva; pressupõe os tokens mapeados no Tailwind (guia de integração no Bloco 4).
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md font-semibold " +
"transition-colors duration-100 focus-visible:outline-none " +
"focus-visible:ring-2 focus-visible:ring-[var(--seed-border-focus)] focus-visible:ring-offset-2 " +
"disabled:cursor-not-allowed disabled:bg-[var(--seed-surface-sunken)] disabled:text-[var(--seed-text-disabled)]",
{
variants: {
variant: {
primary:
"bg-[var(--seed-action-primary)] text-[var(--seed-text-on-brand)] " +
"hover:bg-[var(--seed-action-primary-hover)] active:bg-[var(--seed-action-primary-active)]",
secondary:
"bg-[var(--seed-action-secondary-bg)] border border-[var(--seed-button-secondary-border)] " +
"text-[var(--seed-button-secondary-text)] hover:brightness-95",
outline:
"border border-[var(--seed-button-outline-border)] text-[var(--seed-text-primary)] " +
"hover:bg-[var(--seed-action-ghost-hover)]",
ghost:
"text-[var(--seed-button-ghost-text)] hover:bg-[var(--seed-action-ghost-hover)]",
destructive:
"bg-[var(--seed-action-destructive)] text-white hover:bg-[var(--seed-action-destructive-hover)]",
link:
"h-auto p-0 text-[var(--seed-text-link)] underline-offset-4 hover:underline",
},
size: {
sm: "h-8 px-3 text-xs",
md: "h-11 px-5 text-sm",
lg: "h-[52px] px-7 text-base",
icon: "h-11 w-11",
},
},
defaultVariants: { variant: "primary", size: "md" },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, disabled, children, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading && <Loader2 className="size-4 animate-spin" aria-hidden />}
{children}
</button>
)
);
Button.displayName = "Button";
1.11 Decisões deste bloco (com alternativas descartadas)#
| Decisão | Porquê | Descartado |
|---|---|---|
md = 44px (não 40px) |
Alvo de toque WCAG por padrão, sem gambiarras de hit-area | 40px + pseudo-elemento de expansão (complexidade que alguém esquece) |
| Loading mantém rótulo + spinner à esquerda | Sem layout shift; usuário não perde o contexto do que disparou | Substituir texto por spinner (padrão v1 informal) |
| Outline mantida como variante própria | Útil sobre cards/fotos onde o tonal (turquesa-50) pesa | Fundir outline+secondary (perderia o caso de uso) |
| Disabled com fundo sunken (não opacidade) | Contraste do rótulo continua legível | opacity: .5 (rótulo ilegível, falha WCAG) |
| Link como variante do componente | API única no produto (<Button variant="link">) |
Componente separado (duplicação) |
| Escada de MECANISMOS (v0.2, após crítica do Rafael) | Container de cada variante ≥3:1 (WCAG 1.4.11); v0.1 media 1.07 (secondary) e 2.53 (outline) — variantes indistinguíveis | Diferençar só por tom de tint (reprova em escala de cinza e baixa visão) |
| Tokens de componente com par light/dark obrigatório (v0.5, bug de dark pego pelo Rafael) | No dark, secondary media 1.35:1 e ghost 2.7:1 porque os tokens apontavam pra primitivos fixos | Confiar que "primitivo resolve" (primitivo não troca de modo; só semântico e componente-com-par trocam) |
| Estado selected com sinais visuais no próprio botão (v0.3→v0.4, dois refinamentos do Rafael) | Toggle é o caso nº1 de ambiguidade documentada (NN/g Mute; Google Meet erra); estado deve ler DE RELANCE sem texto | v0.3 sugeria label visível como sinal e redigia "ícone cortado" ambíguo — corrigido para: tooltip como sinal textual, mesmo glifo contorno↔preenchido (spec M3 literal), rasura só na exceção de negação de mídia |
1.12 Processo de avaliação de hierarquia e qualidade (método — vale para qualquer componente; 7 testes desde a v0.7)#
Aplicar SEMPRE que um componente tiver níveis de ênfase; nasceu da revisão do botão (v0.1→v0.2) e vira padrão dos blocos seguintes:
- Teste do container 3:1 (WCAG 1.4.11): o elemento que identifica o controle (preenchimento OU borda) precisa de contraste ≥3:1 contra a superfície. Medir, não estimar.
- Teste da escada de mecanismos: entre níveis adjacentes, muda o MECANISMO (fill → tint+borda → borda → texto), nunca apenas o tom.
- Teste do aperto de olhos / escala de cinza: renderizar o conjunto em grayscale — os níveis continuam distinguíveis? (Remove a muleta do matiz; simula baixa visão e daltonismo.)
- Teste do par: o par mais comum (alta + média ênfase) lado a lado — a hierarquia é óbvia em 1 segundo, à distância de braço?
- Regra do um: 1 alta ênfase por vista; grupos com 1 primário + N iguais de ênfase menor; >3 ações → ghost.
- Teste do estado atual (toggles): alguém que chega AGORA na tela sabe se está ligado ou desligado sem clicar? Se precisa clicar pra descobrir (o "teste WebEx"), reprovou — aplicar a regra dos 3 sinais (§1.5.1). Método de pesquisa (v0.9, calibrado com o Rafael em 2026-08-02 sobre o histórico de 5 itens): todo item roda NO MÍNIMO 3 rodadas de pesquisa, sempre sequenciais e encadeadas — cada rodada abre declarando as pontas soltas que herda da anterior e busca APENAS o que a base acumulada ainda não responde (decisões já estáveis não se re-pesquisam). Arco típico observado no histórico: rodada 1 = canon dos design systems (~60–70% das decisões); rodada 2 = prática das grandes empresas e do mercado (confirmações + ~20%); rodada 3 = normas e fora-do-circuito (menor volume, maior gravidade — foi onde entraram WCAG 3.3.8/NIST, ISO 4217, APG spinbutton, WCAG 2.5.7). Rodada extra (4ª+) por gatilho, nunca por rito: crítica do Rafael expondo lacuna (caso 0800/EUA do form-field), decisões instáveis após 3 rodadas, ou domínio de alto risco (dinheiro, dados pessoais, segurança). Uma rodada que não herda pergunta aberta é redundante e não deve acontecer.
§1.13 — Protocolo de live region SEED (v0.12, transversal): todo anúncio dinâmico a leitores de tela segue UM padrão (nasceu na busca D4, confirmado no textarea F3, agora governa combobox, upload e switch async): região role="status" (polite) + aria-atomic="true", visualmente oculta e separada do nó visual (canais independentes — os 4 bugs do GOV.UK vieram de misturar); anúncio com debounce ~1s (fala na pausa, não a cada evento); frase completa e específica ("14 resultados para 'inversor'", "proposta.pdf enviado"), nunca fragmento; erro que exige ação imediata usa role="alert"; uma live region por componente. Consumidores: §4, §6, §8 (pending), §11, §14.
Nota 1.13-b (v0.15 — emenda, dois regimes de anúncio): o §1.13 nasceu para atualizações contínuas (contagem de busca a cada tecla), onde anunciar cada evento defasaria o visual — daí os canais separados + debounce (D4-b). Mensagens discretas (toast, alerta injetado, banner dinâmico) são conteúdo novo inserido UMA vez: não há defasagem possível, e o padrão universal correto (Spectrum, React Aria, Radix, Base Web, gov.br) é o próprio nó visível ser a live region, injetado já populado. Regra de implementação que a suite cobra nos dois regimes: o container com role precisa existir no DOM ANTES do conteúdo ser inserido — inserir container e conteúdo juntos faz leitores de tela perderem o anúncio (WCAG 4.1.3, técnica documentada). Sem esta nota, a validação executada do Bloco 3 reprovaria o padrão correto por leitura literal do §1.13. Descartado: exigir canal separado também para mensagens discretas (duplicaria o DOM sem ganho e contraria todo o mercado verificado).
- Teste do polegar (v0.7 — mobile): renderizar o componente em viewport de 360px (Android BR mais comum) com teclado virtual aberto (altura útil ~450px): todos os alvos ≥44px ou com hit-area estendida documentada; nada essencial coberto pelo teclado; ordem de leitura íntegra em coluna única; texto do campo ≥16px (anti-zoom iOS); zoom 200% não quebra (WCAG 1.4.4). O preview traz a faixa 360px pré-renderizada, como a faixa grayscale.
O preview traz a faixa em escala de cinza pré-renderizada para o teste 3 ser feito a olho.
Também cita o §1: banco-cn, banco-feedback, tela-autenticacao.