Ir ao conteúdo
SEED engenhariaDesign System

Componentes

1. Botão

estávelseed-componentes.md v1.43 · §01seção 3 de 9801-botao-estavel.md · MD5 ab78f78a

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)
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:

--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).

<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:

  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).

  1. 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.


BancadaSEED — Componentes — Bloco 1: Botãoabrir em página própria ↗

Também cita o §1: banco-cn, banco-feedback, tela-autenticacao.

Esc