États des boutons CSS : survol, focus, actif, désactivé, outline et boutons à icône

Les états de bouton CSS sont les styles qu'un bouton affiche au survol (:hover), au focus clavier (:focus-visible), à l'appui (:active), désactivé (:disabled ou aria-disabled) ou en chargement. Voici un même bouton dans chaque état, avec du CSS pur et du Tailwind à copier, plus des boutons outline, ghost, de fermeture et à icône qui restent accessibles.

Un bouton a six états qui méritent un vrai design : par défaut, survol, focus-visible, actif, désactivé et chargement. La plupart des extraits en ligne n'en montrent qu'un ou deux, sur des boutons différents, avec du code différent. Ce guide utilise un seul bouton du début à la fin, montre chaque état côte à côte et fournit pour chacun du CSS pur et du Tailwind à copier. Il couvre ensuite les variantes les plus recherchées (outline, ghost, fermeture, icône) et la différence entre disabled et aria-disabled, qui va bien au-delà d'un choix de style.

La matrice des états : un bouton, six états

Chaque vignette ci-dessous est dessinée dans un seul état pour que vous puissiez les comparer d'un coup d'œil. Le bouton interactif sous la grille vous permet de forcer un état ou de l'essayer avec votre propre souris et votre clavier.

Enregistrer
Par défaut. État au repos. Le blanc sur blue-600 (#2563eb) donne un contraste d'environ 5.2:1.
Enregistrer
Survol. Pointeur au-dessus. Un cran plus foncé, sans changement de mise en page.
Enregistrer
Focus-visible. Atteint au clavier. Un anneau de 2px avec un espace de 2px, lisible sur n'importe quel fond.
Enregistrer
Actif. Pendant l'appui. Encore plus foncé et décalé de 1px vers le bas.
Enregistrer
Désactivé. Indisponible. Estompé, sans survol, sans curseur main.
Enregistrement
Chargement. En cours. Un spinner plus un verbe qui dit ce qui se passe.
ÉtatSélecteur CSSVariante TailwindCe qui le déclenche
Par défaut.btn(aucun préfixe)Rien
Survol.btn:hoverhover:Un pointeur au-dessus du bouton. Les écrans tactiles n'ont pas de vrai survol.
Focus-visible.btn:focus-visiblefocus-visible:Le focus que le navigateur décide d'afficher, en général le focus clavier (Tab)
Focus.btn:focusfocus:Tout focus, y compris un clic de souris dans la plupart des navigateurs
Actif.btn:activeactive:Tant que le bouton de la souris, le doigt ou la touche Espace reste enfoncé
Désactivé.btn:disableddisabled:L'attribut disabled
Désactivé (ARIA).btn[aria-disabled="true"]aria-disabled:L'attribut aria-disabled="true"
Chargement.btn[aria-busy="true"]aria-busy:Votre script, pendant que le travail est en cours

En CSS pur, l'ordre compte quand les sélecteurs ont la même spécificité : placez :hover en premier, puis :focus-visible, puis :active, pour que l'appui l'emporte sur le survol. Tailwind trie ses variantes pour vous.

Le bouton complet en CSS pur

.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; }
}

Trois détails font l'essentiel du travail. La garde :not(:disabled, [aria-disabled="true"]) empêche un bouton désactivé de réagir au survol ou à l'appui, ce qui le ferait sinon paraître cliquable. La bordure transparente donne à un bouton plein la même taille qu'un bouton outline, si bien que changer de variante ne décale pas la mise en page. Et :focus-visible au lieu de :focus signifie que les utilisateurs de souris ne voient pas d'anneau après un clic, alors que les utilisateurs du clavier le voient toujours.

Utilisez-le ainsi : <button type="button" class="btn">Save changes</button>. Définissez toujours type : un <button> placé dans un formulaire vaut type="submit" par défaut et soumettra le formulaire au clic.

Le même bouton en 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>

Ces classes fonctionnent avec Tailwind 3.4 et 4. Deux remarques pour la v4 : hover: ne s'applique que sur les appareils qui gèrent le survol (@media (hover: hover)), et les styles de base de la v4 donnent aux boutons cursor: default, donc ajoutez cursor-pointer si vous voulez le curseur main. Consultez l'aide-mémoire Tailwind v4 pour les autres valeurs par défaut renommées, comme le ring de 1px.

Focus-visible : l'état impossible à ignorer

Le critère WCAG 2.4.7 Visibilité du focus (niveau AA) exige que le focus clavier soit visible. Supprimer le contour du navigateur avec outline: none sans rien remettre à la place échoue à ce critère. Des règles qui tiennent la route :

L'état de chargement

Un bouton en chargement doit faire quatre choses : montrer qu'un travail est en cours, bloquer une deuxième soumission, garder le focus clavier là où il était et prévenir les utilisateurs de lecteurs d'écran quand le travail se termine. Ajouter disabled pendant la requête casse le troisième point : le bouton perd le focus et, dans certains navigateurs, les utilisateurs du clavier sont renvoyés en haut de la page. Utilisez plutôt aria-disabled et une garde dans votre 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");
  }
});

Gardez un verbe comme libellé ("Enregistrement", "Envoi") au lieu de le remplacer par un simple spinner, gardez une largeur fixe pour le bouton (un min-width l'empêche de rétrécir quand le texte change) et annoncez le résultat via la zone de statut, pas uniquement en changeant le texte du bouton. aria-busy indique que l'élément est en cours de mise à jour ; les lecteurs d'écran le traitent de façons différentes, c'est pourquoi le message de statut porte l'annonce réelle.

disabled ou aria-disabled : la vraie différence

Les deux font annoncer le bouton comme indisponible par les technologies d'assistance (souvent "estompé" ou "indisponible"). La différence tient au comportement. Comme le dit MDN, l'attribut HTML disabled supprime toute fonctionnalité, alors que aria-disabled="true" expose seulement l'état désactivé : c'est donc à vous de supprimer le comportement.

disabledaria-disabled="true"
Accessible avec TabNon, il sort de l'ordre de tabulationOui, il reste focusable
Clic et activation au clavierBloqués par le navigateurToujours déclenchés ; votre script doit les ignorer
Soumet un formulaireNon. Son nom et sa valeur ne sont pas envoyés, et s'il est le bouton par défaut du formulaire, appuyer sur Entrée dans un champ ne soumet rienOui, sauf si vous appelez preventDefault()
Peut afficher une infobulle ou une explication au focusNon, il ne peut pas recevoir le focusOui, avec aria-describedby
Point d'accroche pour le style:disabled / disabled:[aria-disabled="true"] / aria-disabled:
Fonctionne sur les liens (<a>)Non, disabled n'est pas valide sur les liensOui, en retirant aussi href

Règle simple : utilisez disabled quand le bouton ne peut vraiment pas servir et que personne n'a besoin de savoir pourquoi, par exemple un contrôle de formulaire dans une section désactivée. Utilisez aria-disabled quand l'utilisateur doit trouver le bouton et comprendre ce qui manque ("Saisissez votre e-mail pour continuer"), ainsi que pendant le chargement. Souvent, la meilleure réponse n'est ni l'un ni l'autre : laissez le bouton actif et affichez un message d'erreur clair quand on l'actionne avec des champs manquants.

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

Côté contraste : le critère WCAG 1.4.3 exempte le texte des composants inactifs (désactivés) de la règle de contraste minimum, c'est pourquoi un bouton estompé à 50% d'opacité est autorisé. Il vaut quand même la peine de garder le libellé lisible, car les gens doivent voir ce qu'ils ne peuvent pas encore faire.

Boutons outline et ghost

Un bouton outline a un fond transparent et une bordure visible ; un bouton ghost n'a ni l'un ni l'autre avant le survol. Utilisez-les pour les actions moins importantes à côté d'un bouton principal plein : outline pour un second choix clair ("Aperçu"), ghost pour les actions discrètes dans les barres d'outils et les menus ("Annuler", "Modifier").

Ce sont de vrais boutons : survolez-les et appuyez sur Tab pour voir chaque état. Le CSS des variantes, ajouté par-dessus la base .btn ci-dessus :

/* 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>

Le critère WCAG 1.4.11 Contraste du contenu non textuel demande un rapport de 3:1 avec les couleurs adjacentes pour les parties d'un contrôle nécessaires à son identification. La bordure blue-600 du bouton outline sur fond blanc y satisfait. Un bouton ghost n'a pas de contour au repos, il compte donc sur son libellé et sa position pour paraître cliquable : placez les boutons ghost là où l'on attend des actions, comme les barres d'outils, les boîtes de dialogue et les pieds de cartes, et gardez une couleur de texte assez foncée pour le contraste du texte normal (4.5:1).

Bouton de fermeture (X)

Une icône X n'a pas de texte, elle a donc besoin d'un nom accessible. Mettez aria-label="Close" sur le bouton et aria-hidden="true" sur le SVG pour que les lecteurs d'écran annoncent "Close, bouton" au lieu de rien ou d'un nom de fichier. Si une page contient plusieurs boutons de fermeture, donnez-leur des noms précis : "Fermer l'avis sur les cookies", "Fermer la boîte de dialogue".

Vos modifications ont été enregistrées.

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

Dimensionnez la zone cliquable, pas seulement l'icône. Le critère WCAG 2.5.8 Taille de la cible (minimum), niveau AA dans WCAG 2.2, demande des cibles d'au moins 24 par 24 pixels CSS (avec des exceptions, comme un espacement suffisant autour d'une cible plus petite). Le bouton de 40px ci-dessus le respecte largement, tandis que le X dessiné reste à 20px. Évitez d'utiliser la lettre "x" ou le caractère "×" comme seul contenu sans libellé : les lecteurs d'écran le lisent "x" ou "fois".

Boutons icône + texte et icône seule

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

Liste de contrôle avant de livrer un bouton

Plus de styles de boutons

Pour concevoir un bouton visuellement et copier son CSS, utilisez le générateur de boutons CSS. Pour des jeux tout prêts, parcourez les composants de boutons et les boutons animés. Deux ressources qui complètent bien ce guide : le jeu de boutons HTML et CSS avec styles principal, secondaire, outline, ghost, icône, chargement et destructif, et la collection de styles de boutons CSS avec HTML et CSS à copier. Pour savoir où placer les boutons sur une page et ce qu'ils doivent dire, lisez nos exemples d'appels à l'action.

Questions fréquentes

Comment styliser un bouton désactivé en CSS ?

Ciblez la pseudo-classe :disabled, par exemple .btn:disabled { opacity: 0.5; cursor: not-allowed; }. Si vous utilisez aussi aria-disabled, ajoutez [aria-disabled="true"] à la même règle. Protégez vos styles de survol et d'appui avec :not(:disabled) pour qu'un bouton désactivé ne change pas de couleur au survol ou à l'appui.

Quelle est la différence entre disabled et aria-disabled ?

disabled retire le bouton de l'ordre de tabulation, bloque les clics et l'empêche de soumettre un formulaire. aria-disabled="true" indique seulement aux technologies d'assistance que le bouton est indisponible : il reste focusable et cliquable, votre script doit donc ignorer l'activation. Utilisez aria-disabled quand les utilisateurs doivent pouvoir mettre le focus sur le bouton pour comprendre pourquoi il est indisponible, ou pendant le chargement d'une requête.

Faut-il utiliser :focus ou :focus-visible pour les boutons ?

Utilisez :focus-visible pour l'anneau visible. Le navigateur l'affiche pour le focus clavier mais généralement pas après un clic de souris, si bien que les utilisateurs du clavier voient toujours où ils sont sans qu'un anneau apparaisse à chaque clic. Ne supprimez jamais le contour sans ajouter un remplacement visible, car le critère WCAG 2.4.7 exige un indicateur de focus visible.

Comment créer un bouton de fermeture (X) accessible ?

Utilisez un vrai élément button avec type="button" et aria-label="Close" (ou un nom plus précis comme "Fermer la boîte de dialogue"), et mettez aria-hidden="true" sur l'icône X. Donnez à la zone cliquable au moins 24 par 24 pixels CSS, le minimum WCAG 2.2 AA ; 40 à 44 pixels est plus facile à atteindre.

Quelle est la différence entre un bouton outline et un bouton ghost ?

Un bouton outline a un fond transparent et une bordure visible, il se lit donc comme un bouton au repos. Un bouton ghost n'a ni fond ni bordure visible avant le survol, il compte donc sur son texte et sa position. Utilisez outline pour une action secondaire claire et ghost pour les actions discrètes dans les barres d'outils ou les menus.

Comment afficher un état de chargement sur un bouton ?

Ajoutez un spinner avec aria-hidden="true", changez le libellé pour un verbe comme Enregistrement, définissez aria-disabled="true" pour qu'un deuxième clic soit ignoré tout en gardant le focus sur le bouton, et annoncez le résultat via une zone role="status". Retirez les attributs quand la requête se termine ou échoue.

Enregistrement de vos modifications

Enregistré

Forcer un état

interactif (essayez-le)

Enregistrement

Enregistrer

Survolez-le, atteignez-le avec Tab, appuyez dessus, ou cliquez pour voir le chargement.

Affichage de l'état {state}.

L'événement de clic a été déclenché, le script l'a ignoré. Saisissez votre e-mail pour continuer.

Cliquez sur les deux, puis parcourez-les avec Tab : un seul peut être atteint.

Continuer

Événements de clic reçus : {n}

Commencer gratuitement · Sans carte bancaire

Voir aussi: Guides

Produit

Pour qui

Fonctionnalités

Ressources

Entreprise

Plan du site