---
fonte: 01-canonicos/seed-componentes.md
versao_da_fonte: v1.43
secao: 01
titulo: "Botão — `estável`"
sequencia: 3 de 98
bytes_do_corpo: 21496
md5_do_corpo: ab78f78a4a35d492c9e41f74118178d6
gerado_por: 06-validacao/geradores/gen-camada-ia.py
nota: fatia GERADA — o corpo abaixo é byte a byte o trecho do canônico; edite o canônico, nunca esta fatia. Canônico inteiro em https://ds.seed.eng.br/01-canonicos/seed-componentes.md
---
## 1. Botão — `estável`

### 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):**

1. **Placa do container:** desligado = transparente ou contorno sutil; ligado = **placa sólida** (turquesa-800, 8.13:1). Mecanismo, não tom.
2. **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).
3. **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:

```css
--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)

1. **Verbo + objeto, sentence case:** "Solicitar diagnóstico", "Enviar proposta", "Baixar relatório". Nunca "CLIQUE AQUI", nunca "Submeter".
2. **1 a 3 palavras.** Se precisa de mais, o problema é o contexto, não o botão.
3. **Destrutivo nomeia a consequência:** "Excluir proposta" (não "Confirmar", não "Sim"). Em diálogo de confirmação, o par é "Cancelar" + "Excluir proposta".
4. **Loading mantém o rótulo** — sem trocar para "Aguarde..." (o spinner já diz isso).
5. **Sem exclamação, sem urgência falsa** ("Aproveite JÁ!") — o tom SEED é direto e sincero.
6. 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).*

```html
<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>
```

```css
.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).

```tsx
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:

1. **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.
2. **Teste da escada de mecanismos:** entre níveis adjacentes, muda o MECANISMO (fill → tint+borda → borda → texto), nunca apenas o tom.
3. **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.)
4. **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?
5. **Regra do um:** 1 alta ênfase por vista; grupos com 1 primário + N iguais de ênfase menor; >3 ações → ghost.
6. **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).

7. **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.

---

