O que significa “componentizar” botões na prática
Componentizar um botão não é apenas criar uma classe .btn e repetir variações. Na prática, significa definir um componente com fronteiras claras: o que é responsabilidade do botão (estrutura, estados, acessibilidade, variações) e o que não é (layout externo, espaçamento em relação a outros elementos, regras específicas de uma tela). Um botão bem componentizado é previsível: qualquer pessoa do time consegue aplicar uma variação, entender como o estado funciona e garantir que o componente continue acessível.
Um bom componente de botão costuma cobrir três dimensões ao mesmo tempo:
- Variações visuais: primário, secundário, ghost, danger, etc.
- Estados: hover, active, focus-visible, disabled, loading, pressed/toggle.
- Acessibilidade: semântica correta (
<button>vs<a>), foco visível, contraste, área clicável, suporte a teclado e leitores de tela.
O objetivo é que as variações sejam “plugáveis” sem depender de cascata frágil. Em vez de “um botão dentro de um card se comporta diferente”, você define modificadores explícitos e estados previsíveis.
Decisões de design do componente (antes do CSS)
1) Escolher o elemento correto: <button> vs <a>
Regra prática: se a ação dispara comportamento na página (enviar formulário, abrir modal, alternar estado, executar ação), use <button>. Se o objetivo é navegação para outra URL, use <a>. Isso evita gambiarras com JavaScript e melhora a acessibilidade.
<!-- Ação: use button -->
<button class="c-button c-button--primary" type="button">Salvar</button>
<!-- Navegação: use link estilizado como botão -->
<a class="c-button c-button--primary" href="/checkout">Ir para checkout</a>Ao estilizar um <a> como botão, lembre que ele não tem disabled nativo. Se precisar de “desabilitado”, normalmente é melhor não renderizar link desabilitado; em casos raros, use aria-disabled="true" e remova interação (veremos adiante).
Continue em nosso aplicativo
Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.
ou continue lendo abaixo...Baixar o aplicativo
2) Definir o contrato do componente
Um contrato simples para botões costuma incluir:
- Base: aparência padrão, alinhamento, tipografia, padding, border-radius, transições.
- Variações:
--primary,--secondary,--ghost,--danger. - Tamanhos:
--sm,--md,--lg. - Largura:
--block(100%). - Ícone: suporte a ícone à esquerda/direita e botão “apenas ícone”.
- Estados: disabled, loading, focus-visible, pressed (toggle).
Esse contrato evita que cada tela “invente” uma classe nova para o mesmo conceito.
Estrutura HTML recomendada para suportar ícones e loading
Para suportar ícone e spinner sem depender de pseudo-elementos frágeis, uma estrutura interna com elementos auxiliares é mais previsível. Exemplo:
<button class="c-button c-button--primary c-button--md" type="button">
<span class="c-button__icon" aria-hidden="true">
<!-- SVG do ícone -->
</span>
<span class="c-button__label">Salvar</span>
</button>Para loading, você pode incluir um spinner e alternar visibilidade com um modificador:
<button class="c-button c-button--primary is-loading" type="button">
<span class="c-button__spinner" aria-hidden="true"></span>
<span class="c-button__label">Salvando...</span>
</button>Observação importante: se o texto muda para “Salvando...”, isso já comunica o estado para leitores de tela. Se você preferir manter o mesmo texto visual e apenas anunciar, pode usar aria-live em outro lugar; mas, como regra, manter o rótulo coerente é mais simples.
CSS do componente: base com variáveis internas
Uma técnica prática para manter variações consistentes é definir variáveis CSS internas do componente (não confundir com tokens globais). A base do botão consome essas variáveis, e cada modificador apenas ajusta valores.
.c-button {
/* Variáveis internas (valores padrão) */
--_bg: var(--color-surface-1);
--_fg: var(--color-text-1);
--_bd: var(--color-border-1);
--_bg-hover: var(--color-surface-2);
--_fg-hover: var(--color-text-1);
--_bd-hover: var(--color-border-2);
--_bg-active: var(--color-surface-3);
--_bd-active: var(--color-border-2);
--_focus: var(--color-focus);
--_radius: var(--radius-md);
--_font: var(--font-button);
--_gap: var(--space-2);
--_px: var(--space-4);
--_py: var(--space-3);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--_gap);
padding: var(--_py) var(--_px);
border-radius: var(--_radius);
border: 1px solid var(--_bd);
background: var(--_bg);
color: var(--_fg);
font: var(--_font);
text-decoration: none;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 80ms ease;
}
.c-button:hover {
background: var(--_bg-hover);
border-color: var(--_bd-hover);
color: var(--_fg-hover);
}
.c-button:active {
background: var(--_bg-active);
border-color: var(--_bd-active);
transform: translateY(1px);
}
.c-button:focus-visible {
outline: 2px solid var(--_focus);
outline-offset: 2px;
}
.c-button__icon,
.c-button__spinner {
display: inline-flex;
align-items: center;
justify-content: center;
}
.c-button__label {
line-height: 1;
}Esse padrão reduz repetição: você não precisa reescrever padding, border-radius e transições em cada variação. Você só troca variáveis.
Variações: primário, secundário, ghost e danger
Agora, os modificadores ajustam as variáveis internas. Exemplo:
.c-button--primary {
--_bg: var(--color-primary-600);
--_fg: var(--color-on-primary);
--_bd: var(--color-primary-600);
--_bg-hover: var(--color-primary-700);
--_bd-hover: var(--color-primary-700);
--_bg-active: var(--color-primary-800);
--_bd-active: var(--color-primary-800);
}
.c-button--secondary {
--_bg: var(--color-surface-1);
--_fg: var(--color-text-1);
--_bd: var(--color-border-2);
--_bg-hover: var(--color-surface-2);
--_bd-hover: var(--color-border-3);
--_bg-active: var(--color-surface-3);
}
.c-button--ghost {
--_bg: transparent;
--_fg: var(--color-text-1);
--_bd: transparent;
--_bg-hover: var(--color-surface-2);
--_bd-hover: transparent;
--_bg-active: var(--color-surface-3);
}
.c-button--danger {
--_bg: var(--color-danger-600);
--_fg: var(--color-on-danger);
--_bd: var(--color-danger-600);
--_bg-hover: var(--color-danger-700);
--_bd-hover: var(--color-danger-700);
--_bg-active: var(--color-danger-800);
--_bd-active: var(--color-danger-800);
}Note que a base continua a mesma. Isso facilita adicionar novas variações sem duplicar estilos e sem aumentar especificidade.
Tamanhos e densidade: sm, md, lg
Tamanho de botão não é só fonte; envolve padding, altura mínima e, às vezes, o tamanho do ícone. Um padrão simples:
.c-button--sm {
--_px: var(--space-3);
--_py: var(--space-2);
--_radius: var(--radius-sm);
}
.c-button--md {
--_px: var(--space-4);
--_py: var(--space-3);
--_radius: var(--radius-md);
}
.c-button--lg {
--_px: var(--space-5);
--_py: var(--space-4);
--_radius: var(--radius-lg);
}Se o produto precisa de consistência de altura, você pode adicionar min-height na base e ajustar por tamanho:
.c-button { min-height: 40px; }
.c-button--sm { min-height: 32px; }
.c-button--lg { min-height: 48px; }Botão em bloco e alinhamento
Evite que o componente imponha margens externas. Mas é comum oferecer um modificador para ocupar a largura disponível:
.c-button--block {
width: 100%;
}Como o botão usa inline-flex, ao virar bloco ele continua centralizando conteúdo por padrão. Se você precisar alinhar texto à esquerda em botões largos (ex.: listas), crie um modificador explícito:
.c-button--align-start {
justify-content: flex-start;
}Estados essenciais: disabled e focus-visible
Disabled nativo em <button>
Para <button disabled>, o navegador já remove foco e clique. Seu CSS deve comunicar visualmente e evitar hover/active enganoso.
.c-button:disabled,
.c-button[aria-disabled="true"] {
opacity: 0.55;
cursor: not-allowed;
}
.c-button:disabled:hover,
.c-button[aria-disabled="true"]:hover {
background: var(--_bg);
border-color: var(--_bd);
color: var(--_fg);
transform: none;
}Por que incluir [aria-disabled="true"]? Para cobrir o caso de links estilizados como botão, onde você não tem disabled nativo.
Disabled em <a> (link como botão)
Se você realmente precisar de um link “desabilitado”, aplique aria-disabled="true", remova o href (ou bloqueie com JS) e evite que ele receba clique. Um padrão seguro é não renderizar como link quando estiver desabilitado; renderize como <button> ou <span> dependendo do caso. Se ainda assim usar <a>:
<a class="c-button c-button--secondary" aria-disabled="true" tabindex="-1">Indisponível</a>tabindex="-1" remove do fluxo de tabulação. Isso evita que o usuário de teclado fique “preso” em um elemento que não faz nada.
Focus visível sem poluir cliques
Use :focus-visible para mostrar outline apenas quando o foco veio do teclado (ou heurística do navegador). Isso melhora a experiência sem remover acessibilidade.
.c-button:focus { outline: none; }
.c-button:focus-visible {
outline: 2px solid var(--_focus);
outline-offset: 2px;
}Evite remover outline sem repor um indicador equivalente.
Estado de loading: bloquear interação e manter layout estável
Loading costuma gerar dois problemas: (1) o usuário clica várias vezes; (2) o texto muda e o botão “pula” de largura. Dá para resolver com um modificador .is-loading e algumas regras.
.c-button.is-loading {
pointer-events: none;
}
.c-button__spinner {
width: 1em;
height: 1em;
border-radius: 999px;
border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
border-top-color: currentColor;
animation: c-button-spin 700ms linear infinite;
}
@keyframes c-button-spin {
to { transform: rotate(360deg); }
}Para estabilidade de largura, uma abordagem simples é manter o rótulo com o mesmo comprimento (ex.: “Salvar” vs “Salvando...”), mas isso nem sempre é possível. Outra opção é definir min-width em botões críticos (ex.: botões de formulário) via utilitário ou modificador do componente.
.c-button--minw-160 { min-width: 160px; }Se você preferir esconder o texto durante loading e mostrar apenas spinner, garanta acessibilidade com um rótulo que continue disponível para leitores de tela:
<button class="c-button c-button--primary is-loading" type="button" aria-busy="true">
<span class="c-button__spinner" aria-hidden="true"></span>
<span class="c-button__label">
<span class="u-visually-hidden">Salvando</span>
</span>
</button>Isso depende de uma classe utilitária .u-visually-hidden já existente no projeto. Se não existir, crie uma utilitária padrão (sem acoplar ao componente) para esconder visualmente e manter acessível.
Botão com ícone e botão “apenas ícone”
Ícone + texto
Com a estrutura __icon e __label, o alinhamento fica consistente. Para garantir tamanho de ícone:
.c-button__icon svg {
width: 1em;
height: 1em;
}Apenas ícone (icon button)
Botões apenas com ícone precisam de rótulo acessível. Visualmente, não há texto; para leitores de tela, você deve fornecer aria-label (ou texto escondido).
<button class="c-button c-button--ghost c-button--icon" type="button" aria-label="Fechar">
<span class="c-button__icon" aria-hidden="true">...svg...</span>
</button>.c-button--icon {
--_px: var(--space-3);
--_py: var(--space-3);
width: 40px;
height: 40px;
padding: 0;
}
.c-button--icon .c-button__icon {
width: 1.25em;
height: 1.25em;
}Garanta área clicável adequada. Mesmo que o ícone seja pequeno, o botão deve ter tamanho confortável para toque.
Botão toggle (pressed): quando usar aria-pressed
Quando o botão alterna um estado (ex.: “favoritar”, “ativar filtro”), use aria-pressed para comunicar que é um botão de alternância. O valor deve refletir o estado real: true ou false.
<button class="c-button c-button--secondary is-pressed" type="button" aria-pressed="true">
Favorito
</button>No CSS, trate .is-pressed como um estado, não como variação. Ele pode alterar as variáveis internas para dar feedback visual.
.c-button.is-pressed {
--_bg: var(--color-primary-100);
--_bd: var(--color-primary-300);
--_fg: var(--color-primary-800);
}
.c-button.is-pressed:hover {
--_bg-hover: var(--color-primary-200);
}Evite usar aria-pressed em botões que não alternam estado; isso confunde tecnologias assistivas.
Passo a passo prático: montar um kit de botões consistente
Passo 1: listar casos de uso reais
Antes de codar, liste onde botões aparecem no produto e quais necessidades existem:
- CTA principal em páginas (primário, grande).
- Ações secundárias em formulários (secundário, médio).
- Ações destrutivas (danger).
- Ações discretas em listas e tabelas (ghost).
- Ícones (fechar, editar, mais opções).
- Estados: desabilitado por validação, loading ao enviar, toggle em filtros.
Isso evita criar variações “por estética” e ajuda a manter o conjunto pequeno e útil.
Passo 2: definir a API de classes do componente
Escolha um conjunto enxuto de modificadores e estados. Exemplo de API:
.c-button(base).c-button--primary | --secondary | --ghost | --danger(variações).c-button--sm | --md | --lg(tamanhos).c-button--block(largura).c-button--icon(apenas ícone).is-loading,.is-pressed(estados)
O importante é consistência: variações com --, estados com is-. Assim, ao ler o HTML, fica claro o que muda aparência “por tipo” e o que muda “por estado”.
Passo 3: implementar a base com variáveis internas
Implemente .c-button consumindo variáveis internas (cores, borda, padding). Isso reduz duplicação e facilita manutenção. Garanta desde já:
- Foco visível com
:focus-visible. - Cursor e transições moderadas.
- Alinhamento com
inline-flexegap. - Sem margens externas.
Passo 4: adicionar variações ajustando apenas variáveis
Crie --primary, --secondary, etc., alterando --_bg, --_fg, --_bd e seus equivalentes de hover/active. Evite reescrever propriedades estruturais (display, padding, border-radius) nas variações.
Passo 5: adicionar tamanhos e garantir área clicável
Implemente --sm, --md, --lg ajustando --_px, --_py e, se necessário, min-height. Verifique se o tamanho menor ainda é clicável em touch e confortável para teclado.
Passo 6: implementar disabled e loading com regras claras
Para <button>, use :disabled. Para links, use [aria-disabled="true"] e remova do tab com tabindex="-1". Para loading, use .is-loading com pointer-events: none e um spinner com aria-hidden. Se o texto não mudar, forneça rótulo acessível (aria-label ou texto escondido).
Passo 7: validar acessibilidade com checklist rápido
- Semântica: ações são
<button>; navegação é<a>. - Foco: ao navegar com Tab, o foco é visível e consistente.
- Contraste: texto e fundo têm contraste suficiente em todas as variações e estados (incluindo hover/active/disabled).
- Icon-only: sempre tem
aria-labelou texto escondido. - Toggle: usa
aria-pressede estado visual coerente. - Loading: impede múltiplos cliques e comunica o estado.
Exemplos completos de uso
Primário grande com ícone
<button class="c-button c-button--primary c-button--lg" type="button">
<span class="c-button__icon" aria-hidden="true">...svg...</span>
<span class="c-button__label">Criar conta</span>
</button>Secundário desabilitado por validação
<button class="c-button c-button--secondary c-button--md" type="submit" disabled>
<span class="c-button__label">Continuar</span>
</button>Ghost em lista (largura total e alinhado à esquerda)
<button class="c-button c-button--ghost c-button--block c-button--align-start" type="button">
<span class="c-button__label">Ver detalhes</span>
</button>Botão de alternância (pressed)
<button class="c-button c-button--secondary is-pressed" type="button" aria-pressed="true">
<span class="c-button__label">Filtro ativo</span>
</button>Link com aparência de botão (navegação)
<a class="c-button c-button--primary c-button--md" href="/planos">
<span class="c-button__label">Ver planos</span>
</a>Armadilhas comuns e como evitar
1) Misturar variação com estado
Se “loading” vira --loading e “pressed” vira --pressed, você começa a tratar estado como tipo, e as combinações explodem. Prefira estados como .is-loading e .is-pressed, que podem coexistir com qualquer variação.
2) Criar exceções por contexto
Evite regras como .c-card .c-button { ... }. Isso acopla componentes e cria efeitos colaterais. Se um botão precisa de uma aparência diferente em um contexto específico, crie um modificador explícito do botão (se for um padrão reutilizável) ou uma composição de classes no HTML (se for um caso pontual).
3) Remover outline sem alternativa
Um botão sem indicador de foco quebra navegação por teclado. Use :focus-visible e mantenha contraste do outline com o fundo.
4) Botão apenas ícone sem nome acessível
Se não há texto visível, sempre forneça aria-label (ou texto escondido). O ícone sozinho não é um nome acessível.
5) Disabled “falso” que ainda recebe foco
Para <button>, use disabled. Para <a>, se for inevitável, use aria-disabled="true" e remova do tab com tabindex="-1". Caso contrário, o usuário de teclado chega no elemento e nada acontece.