Estados de botão em CSS: hover, focus, active, disabled, outline e botões com ícone

Os estados de botão em CSS são os estilos que um botão mostra quando recebe hover (:hover), foco pelo teclado (:focus-visible), é pressionado (:active), está desativado (:disabled ou aria-disabled) ou carregando. Aqui está um único botão em todos os estados, com CSS puro e Tailwind para copiar, além de botões outline, ghost, de fechar e com ícone que continuam acessíveis.

Um botão tem seis estados que merecem design: padrão, hover, focus-visible, ativo, desativado e carregando. A maioria dos trechos de código na internet mostra um ou dois deles, em botões diferentes, com códigos diferentes. Este guia usa um único botão do começo ao fim, mostra todos os estados lado a lado e traz CSS puro e Tailwind para copiar em cada um. Depois, cobre as variantes mais pesquisadas (outline, ghost, fechar, ícone) e a diferença entre disabled e aria-disabled, que vai além de uma escolha de estilo.

A matriz de estados: um botão, seis estados

Cada bloco abaixo está desenhado em um estado, para você comparar todos de relance. O botão ao vivo embaixo da grade permite forçar um estado ou testá-lo com o seu próprio mouse e teclado.

Salvar alterações
Padrão. Estado em repouso. Branco sobre blue-600 (#2563eb) dá cerca de 5.2:1 de contraste.
Salvar alterações
Hover. Ponteiro sobre o botão. Um tom mais escuro; o layout não muda.
Salvar alterações
Focus-visible. Alcançado pelo teclado. Um anel de 2px com um espaço de 2px, para ficar legível em qualquer fundo.
Salvar alterações
Ativo. Enquanto é pressionado. Mais escuro ainda e deslocado 1px para baixo.
Salvar alterações
Desativado. Indisponível. Esmaecido, sem hover, sem cursor de mão.
Salvando
Carregando. Em andamento. Spinner mais um verbo que diz o que está acontecendo.
EstadoSeletor CSSVariante do TailwindO que o ativa
Padrão.btn(sem prefixo)Nada
Hover.btn:hoverhover:Um ponteiro sobre o botão. Telas sensíveis ao toque não têm hover de verdade.
Focus-visible.btn:focus-visiblefocus-visible:Foco que o navegador decide mostrar, normalmente o foco pelo teclado (Tab)
Focus.btn:focusfocus:Qualquer foco, incluindo um clique do mouse na maioria dos navegadores
Ativo.btn:activeactive:Enquanto o botão do mouse, o dedo ou a tecla Space estão pressionados
Desativado.btn:disableddisabled:O atributo disabled
Desativado (ARIA).btn[aria-disabled="true"]aria-disabled:O atributo aria-disabled="true"
Carregando.btn[aria-busy="true"]aria-busy:O seu script, enquanto o trabalho está em andamento

A ordem importa no CSS puro quando os seletores têm a mesma especificidade: coloque :hover primeiro, depois :focus-visible e depois :active, para que pressionar vença o hover. O Tailwind ordena as variantes por você.

O botão completo em CSS puro

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: 2.5rem;              /* 40px, above the 24px WCAG minimum */
  padding: 0.625rem 1rem;
  border: 1px solid transparent;    /* keeps size equal to the outline variant */
  border-radius: 0.5rem;
  background: #2563eb;
  color: #fff;
  font: 600 0.875rem/1.25rem system-ui, sans-serif;
  cursor: pointer;
  transition: background-color 150ms, transform 150ms;
}

/* hover only when the button can actually be used */
.btn:hover:not(:disabled, [aria-disabled="true"]) {
  background: #1d4ed8;
}

/* keyboard focus; mouse clicks do not show the ring */
.btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

.btn:active:not(:disabled, [aria-disabled="true"]) {
  background: #1e40af;
  transform: translateY(1px);
}

.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn[aria-busy="true"] {
  cursor: progress;
}

@media (prefers-reduced-motion: reduce) {
  .btn { transition: none; }
  .btn:active { transform: none; }
}

Três detalhes fazem a maior parte do trabalho. A proteção :not(:disabled, [aria-disabled="true"]) impede que um botão desativado reaja ao hover ou ao clique, o que de outra forma o faria parecer clicável. A borda transparente mantém um botão sólido do mesmo tamanho de um outline, então trocar de variante não desloca o layout. E :focus-visible em vez de :focus significa que quem usa mouse não vê um anel depois de clicar, enquanto quem usa teclado sempre vê.

Use assim: <button type="button" class="btn">Save changes</button>. Sempre defina o type: um <button> dentro de um formulário usa type="submit" por padrão e envia o formulário quando clicado.

O mesmo botão em Tailwind

<button type="button"
  class="inline-flex items-center justify-center gap-2 rounded-lg border border-transparent
         bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white transition
         hover:bg-blue-700 active:translate-y-px active:bg-blue-800
         focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600
         disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-blue-600 disabled:active:translate-y-0
         aria-disabled:cursor-not-allowed aria-disabled:opacity-50
         motion-reduce:transition-none">
  Save changes
</button>

Essas classes funcionam no Tailwind 3.4 e 4. Duas observações sobre a v4: hover: só se aplica em dispositivos que suportam hover (@media (hover: hover)), e os estilos base da v4 dão aos botões cursor: default, então adicione cursor-pointer se quiser o cursor de mão. Veja o guia rápido do Tailwind v4 para os outros padrões renomeados, como o ring de 1px.

Focus-visible: o estado que você não pode pular

O critério WCAG 2.4.7 Focus Visible (nível AA) exige que o foco do teclado seja visível. Remover o contorno do navegador com outline: none sem colocar nada no lugar reprova nesse critério. Diretrizes que funcionam:

O estado de carregamento

Um botão em carregamento precisa fazer quatro coisas: mostrar que há trabalho em andamento, bloquear um segundo envio, manter o foco do teclado onde estava e avisar quem usa leitor de tela quando o trabalho terminar. Definir disabled durante a requisição quebra o terceiro ponto: o botão perde o foco e, em alguns navegadores, quem usa teclado volta para o topo da página. Use aria-disabled e faça a proteção no seu script.

<button type="submit" class="btn" aria-disabled="true" aria-busy="true">
  <svg class="spinner" viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
    <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity=".3" stroke-width="3"/>
    <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
  </svg>
  Saving
</button>
<!-- one live region on the page, outside the button -->
<p class="visually-hidden" role="status">Saving your changes</p>
.spinner { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .spinner { animation-duration: 2.4s; }  /* slow it, keep a sign of progress */
}
.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (button.getAttribute("aria-disabled") === "true") return; // ignore double clicks
  button.setAttribute("aria-disabled", "true");
  button.setAttribute("aria-busy", "true");
  status.textContent = "Saving your changes";
  try {
    await save(new FormData(form));
    status.textContent = "Saved";
  } catch {
    status.textContent = "Could not save. Try again.";
  } finally {
    button.removeAttribute("aria-disabled");
    button.removeAttribute("aria-busy");
  }
});

Mantenha o rótulo como um verbo ("Salvando", "Enviando") em vez de trocá-lo só por um spinner, mantenha a largura do botão fixa (um min-width impede que ele encolha quando o texto muda) e anuncie o resultado pela região de status, não só mudando o texto do botão. aria-busy é uma indicação de que o elemento está sendo atualizado; os leitores de tela variam no que fazem com ele, e é por isso que a mensagem de status leva o anúncio de fato.

disabled vs aria-disabled: a diferença real

Os dois fazem a tecnologia assistiva anunciar o botão como indisponível (muitas vezes "esmaecido" ou "indisponível"). A diferença está no comportamento. Como diz o MDN, o atributo HTML disabled suprime toda a funcionalidade, enquanto aria-disabled="true" só expõe o estado desativado, então você mesmo precisa suprimir o comportamento.

disabledaria-disabled="true"
Alcançável com TabNão, ele sai da ordem de tabulaçãoSim, continua focável
Clique e ativação pelo tecladoBloqueados pelo navegadorContinuam disparando; o seu script precisa ignorá-los
Envia um formulárioNão. O name e o value dele não são enviados, e se ele for o botão padrão do formulário, apertar Enter em um campo não enviaSim, a menos que você chame preventDefault()
Pode mostrar uma dica ou explicação ao receber focoNão, ele não recebe focoSim, com aria-describedby
Gancho de estilo:disabled / disabled:[aria-disabled="true"] / aria-disabled:
Funciona em links (<a>)Não, disabled não é válido em linksSim, junto com a remoção do href

Regra prática: use disabled quando o botão realmente não pode ser usado e ninguém precisa descobrir por quê, por exemplo um controle de formulário dentro de uma seção desligada. Use aria-disabled quando o usuário precisa encontrar o botão e entender o que falta ("Preencha seu e-mail para continuar"), e durante o carregamento. Muitas vezes a melhor resposta é nenhum dos dois: deixe o botão ativo e mostre uma mensagem de erro clara quando ele for pressionado com dados faltando.

button.addEventListener("click", (event) => {
  if (button.getAttribute("aria-disabled") === "true") {
    event.preventDefault();   // also stops a submit button from submitting
    return;
  }
  doTheThing();
});

Sobre contraste: o critério WCAG 1.4.3 isenta o texto de componentes inativos (desativados) da regra de contraste mínimo, e é por isso que um botão esmaecido com opacidade de 50% é permitido. Ainda assim vale manter o rótulo legível, porque as pessoas precisam ver o que ainda não podem fazer.

Botões outline vs ghost

Um botão outline tem preenchimento transparente e borda visível; um botão ghost não tem nenhum dos dois até receber hover. Use-os para ações menos importantes ao lado de um botão primário sólido: outline para uma segunda opção clara ("Visualizar"), ghost para ações discretas em barras de ferramentas e menus ("Cancelar", "Editar").

Esses são botões de verdade: passe o mouse sobre eles e aperte Tab para ver cada estado. O CSS das variantes, aplicado sobre a base .btn acima:

/* Outline: transparent fill, visible border */
.btn-outline {
  background: transparent;
  color: #1d4ed8;
  border-color: #2563eb;
}
.btn-outline:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-outline:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }

/* Ghost: no fill, no visible border until hover */
.btn-ghost {
  background: transparent;
  color: #1d4ed8;
  border-color: transparent;
}
.btn-ghost:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-ghost:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }

/* Destructive */
.btn-danger { background: #dc2626; }
.btn-danger:hover:not(:disabled, [aria-disabled="true"]) { background: #b91c1c; }
.btn-danger:focus-visible { outline-color: #dc2626; }
<!-- Outline -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-blue-600 bg-transparent px-4 py-2.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 active:bg-blue-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50">
  Outline
</button>

<!-- Ghost -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-transparent px-4 py-2.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 active:bg-blue-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50">
  Ghost
</button>

<!-- Destructive -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-transparent bg-red-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-red-700 active:bg-red-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 disabled:opacity-50">
  Delete project
</button>

O critério WCAG 1.4.11 Non-text Contrast pede 3:1 em relação às cores vizinhas para as partes de um controle necessárias para identificá-lo. A borda blue-600 do outline sobre branco passa nisso. Um botão ghost não tem contorno em repouso, então depende do rótulo e da posição para parecer clicável: mantenha os botões ghost onde as pessoas esperam ações, como barras de ferramentas, diálogos e rodapés de cards, e mantenha a cor do texto escura o bastante para o contraste de texto normal (4.5:1).

Botão de fechar (X)

Um ícone X não tem texto, então precisa de um nome acessível. Coloque aria-label="Close" no botão e aria-hidden="true" no SVG para que os leitores de tela anunciem "Fechar, botão" em vez de nada ou de um nome de arquivo. Se uma página tiver vários botões de fechar, deixe os nomes específicos: "Fechar aviso de cookies", "Fechar diálogo".

Suas alterações foram salvas.

<button type="button" class="btn-close" aria-label="Close">
  <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
    <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
  </svg>
</button>
.btn-close {
  display: inline-grid;
  place-items: center;
  width: 2.5rem;
  height: 2.5rem;          /* 40px target; WCAG 2.2 AA minimum is 24 by 24 */
  padding: 0;
  border: 0;
  border-radius: 9999px;
  background: transparent;
  color: #334155;
  cursor: pointer;
}
.btn-close:hover { background: rgb(15 23 42 / 0.08); }
.btn-close:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
<button type="button" aria-label="Close"
  class="inline-grid size-10 place-items-center rounded-full text-slate-700 hover:bg-slate-900/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
  <svg viewBox="0 0 24 24" class="size-5" aria-hidden="true" focusable="false">
    <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
  </svg>
</button>

Dimensione a área de clique, não só o ícone. O critério WCAG 2.5.8 Target Size (Minimum), nível AA na WCAG 2.2, pede alvos de pelo menos 24 por 24 pixels CSS (com exceções, como espaço suficiente em volta de um alvo menor). O botão de 40px acima passa com folga, enquanto o X desenhado fica com 20px. Evite usar a letra "x" ou o caractere "×" como único conteúdo sem um rótulo: os leitores de tela o leem como "x" ou "vezes".

Botões com ícone e texto e só com ícone

<!-- Icon + text: the text is the name, the icon is decoration -->
<button type="button" class="btn">
  <svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24">...</svg>
  Download PDF
</button>

<!-- Icon only, option 1: aria-label -->
<button type="button" class="btn btn-icon" aria-label="Download PDF">
  <svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
</button>

<!-- Icon only, option 2: visually hidden text (gets translated by browser translation tools) -->
<button type="button" class="btn btn-icon">
  <svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
  <span class="visually-hidden">Download PDF</span>
</button>
.btn svg { flex: none; }          /* keep icons from squashing */
.btn-icon {
  width: 2.5rem;
  padding: 0;                       /* square: same width and height */
}
<button type="button" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
  <svg class="size-4 shrink-0" aria-hidden="true" ...></svg>
  Download PDF
</button>

<button type="button" aria-label="Download PDF" class="inline-grid size-10 place-items-center rounded-lg bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
  <svg class="size-5" aria-hidden="true" ...></svg>
</button>

Checklist antes de publicar um botão

Mais estilos de botão

Para desenhar um botão visualmente e copiar o CSS dele, use o gerador de botões CSS. Para conjuntos prontos, navegue pelos componentes de botão e pelos botões animados. Duas peças que combinam bem com este guia: o conjunto de botões em HTML e CSS com estilos primário, secundário, outline, ghost, ícone, carregando e destrutivo e a coleção de estilos de botão CSS com HTML e CSS para copiar. Para saber onde os botões ficam em uma página e o que devem dizer, leia os exemplos de call to action.

Perguntas frequentes

Como estilizo um botão desativado em CSS?

Use a pseudo-classe :disabled, por exemplo .btn:disabled { opacity: 0.5; cursor: not-allowed; }. Se você também usa aria-disabled, adicione [aria-disabled="true"] à mesma regra. Proteja os estilos de hover e active com :not(:disabled) para que um botão desativado não mude de cor quando recebe hover ou é pressionado.

Qual é a diferença entre disabled e aria-disabled?

disabled tira o botão da ordem de tabulação, bloqueia os cliques e impede que ele envie um formulário. aria-disabled="true" só informa à tecnologia assistiva que o botão está indisponível: ele continua focável e clicável, então o seu script precisa ignorar a ativação. Use aria-disabled quando o usuário precisa focar o botão para entender por que ele está indisponível, ou enquanto uma requisição está carregando.

Devo usar :focus ou :focus-visible em botões?

Use :focus-visible para o anel visível. O navegador o mostra no foco pelo teclado, mas normalmente não depois de um clique do mouse, então quem usa teclado sempre vê onde está sem que um anel apareça a cada clique. Nunca remova o contorno sem colocar um substituto visível, já que o WCAG 2.4.7 exige um indicador de foco visível.

Como faço um botão de fechar (X) acessível?

Use um elemento button de verdade com type="button" e aria-label="Close" (ou um nome mais específico, como "Close dialog"), e coloque aria-hidden="true" no ícone X. Faça a área clicável ter pelo menos 24 por 24 pixels CSS, o mínimo da WCAG 2.2 AA; 40 a 44 pixels é mais fácil de acertar.

Qual é a diferença entre um botão outline e um botão ghost?

Um botão outline tem fundo transparente e borda visível, então parece um botão mesmo em repouso. Um botão ghost não tem fundo nem borda visível até receber hover, então depende do texto e da posição. Use outline para uma ação secundária clara e ghost para ações discretas em barras de ferramentas ou menus.

Como mostro um estado de carregamento em um botão?

Adicione um spinner com aria-hidden="true", troque o rótulo por um verbo como Salvando, defina aria-disabled="true" para que um segundo clique seja ignorado enquanto o foco fica no botão e anuncie o resultado por uma região role="status". Remova os atributos quando a requisição terminar ou falhar.

Salvando suas alterações

Salvo

Forçar um estado

ao vivo (teste)

Salvando

Salvar alterações

Passe o mouse, chegue até ele com Tab, pressione ou clique para ver o carregamento.

Mostrando o estado {state}.

O evento de clique disparou e o script o ignorou. Preencha seu e-mail para continuar.

Clique nos dois e depois percorra-os com Tab: só um pode ser alcançado.

Continuar

Eventos de clique recebidos: {n}

Comece grátis · Sem cartão de crédito

Relacionados: Guias

Produto

Para quem é

Recursos

Recursos

Empresa

Mapa do site