Cases à cocher et boutons radio personnalisés en pur CSS : modèles accessibles qui ne trompent pas

Cet article vous a aidé ?

Vous publiez un nouveau système de design. Le marketing adore les cases « propres ». Les tickets support arrivent quand même : « Je ne vois pas ce qui est sélectionné », « Le clavier ne fonctionne pas », « Le lecteur d’écran dit ‘vide’ ». Pendant ce temps, votre cerveau SRE hurle : nous venons de déployer une régression UI avec la même énergie qu’un mauvais déploiement de config — silencieuse au début, puis coûteuse.

Les contrôles de formulaire personnalisés ne sont pas difficiles. Les contrôles qui mentent le sont. Cet article traite de la création de cases à cocher/boutons radio personnalisés en pur CSS tout en conservant la sémantique native, le comportement clavier et la survie en contraste élevé. Pas de magie JS, pas de théâtre d’accessibilité.

Non négociables : ce que « accessible » signifie réellement pour les cases/boutons

Les cases à cocher et les boutons radio sont volontairement ennuyeux. Ce sont parmi les éléments d’interaction les plus normalisés de toute la plateforme web. Le navigateur vous fournit la sémantique, le comportement clavier, la gestion du focus, le branchement à l’API d’accessibilité et la compatibilité avec les technologies d’assistance — en gros un petit miracle qui voyage sur des milliards d’appareils.

Quand les équipes les « personnalisent », l’erreur la plus fréquente est de remplacer ce miracle par une div et des bonnes intentions. Parfois ça passe un audit superficiel : visuellement cliquable et bascule à la souris. Mais ça échoue dès qu’on tente de naviguer au clavier, d’utiliser le mode contraste élevé, de zoomer à 200 % ou d’exécuter un lecteur d’écran. En termes ops : ça passe en staging où tout le monde est sur le même MacBook ; ça s’effondre en production où la flotte est la réalité.

Définition : un contrôle personnalisé qui ne ment pas

Une case/bouton radio personnalisé « ne ment pas » s’il respecte ces contraintes :

  • L’élément natif reste la source de vérité. Utilisez <input type="checkbox"> / <input type="radio">. Ne recréez pas leur comportement en JS.
  • Le libellé est réel. Utilisez <label for="…"> ou enveloppez l’input dans un label pour que la cible clic/tap fonctionne sans bricolage.
  • Le clavier fonctionne par défaut. Tab cible l’input ; Espace bascule la case ; les flèches naviguent dans les groupes radio (règles du navigateur).
  • Le focus est visible. Surtout avec :focus-visible. Pas de « nous ferons une ombre discrète » qui disparaît en plein soleil.
  • Les états sont représentés. Coché/décoché, désactivé, invalide et (pour les cases) indéterminé.
  • Le contraste forcé et les couleurs imposées ne le cassent pas. Si vous dessinez tout avec des images de fond, forced-colors rendra votre contrôle fantomatique.

Opinion : Si vous ne pouvez pas garder l’input natif dans le DOM, n’implémentez pas de cases personnalisées. Choisissez un design visuel qui fonctionne avec la plateforme. Vos guidelines de marque ne paieront pas votre règlement ADA.

Une citation à coller sur votre écran

« L’espoir n’est pas une stratégie. » — Gordon R. Dickson

Ce n’est pas une citation web, mais une vérité d’ingénierie : espérer que votre UI personnalisée se comporte comme une case est la manière d’expédier des incidents en forme humaine.

Faits et contexte historique à connaître

Un peu de contexte aide à voir pourquoi les contraintes d’aujourd’hui ne sont pas arbitraires. Voici des faits concrets qui influencent votre conception :

  1. Les premiers formulaires web reproduisaient les formulaires papier. Cases et radios devaient être prévisibles, pas brandables. Ce baseline « ennuyeux » explique leur interopérabilité.
  2. Le CSS ne permettait pas de styliser de manière fiable les contrôles natifs pendant des années. Les navigateurs traitaient les inputs comme des widgets OS avec peu d’accroches ; les « contrôles personnalisés » sont devenus une industrie de hacks.
  3. L’association label-input est antérieure à la plupart des frameworks. Le pattern for/id est une affordance d’utilisabilité fondamentale, pas un ajout d’accessibilité.
  4. Les groupes radio ont une sémantique clavier intégrée. La navigation par flèche et la sélection mutuellement exclusive sont gérées par le navigateur quand le name est identique.
  5. :focus-visible existe parce que les anneaux de focus se faisaient supprimer. Les designers enlevaient les outlines ; les utilisateurs perdaient la trace. Les navigateurs ont répondu par une heuristique plus intelligente.
  6. Le mode forced-colors n’est pas anecdotique. Le mode contraste élevé Windows (et les forced colors modernes) est utilisé par des personnes qui ne peuvent pas lire une UI à faible contraste — pas par des fans de vos dégradés.
  7. Les icônes SVG ne sont pas la sémantique d’accessibilité. Dessiner une coche n’informe pas l’arbre d’accessibilité. L’input le fait.
  8. L’état indéterminé est réel. C’est un état UI, pas une valeur ; il ne soumettra pas « peut-être ». Il sert souvent pour « Tout sélectionner » avec sélections partielles.
  9. Les navigateurs diffèrent sur les tailles et l’alignement par défaut. Si vous comptez sur des métriques par défaut, votre maquette pixel-perfect dérivera selon les plateformes. Si vous remplacez la sémantique, c’est le comportement qui dérivera. Choisissez votre dérive.

Rien de tout cela n’est anecdotique. Chaque point explique pourquoi le pattern « div checkbox » continue de casser chez de vrais utilisateurs.

Modèles qui fonctionnent avec du pur CSS (et pourquoi)

Pattern A : masquer visuellement l’input natif, styliser un sibling

C’est la solution de base. Vous gardez le vrai input dans le DOM, focalisable et interactif, mais masqué visuellement. Ensuite vous stylisez un span (ou similaire) comme la « case/cercle » en utilisant les sélecteurs input:checked + .control.

Pourquoi ça marche : le navigateur garde le comportement, les technologies d’assistance voient toujours une case/radio, les formulaires soumettent correctement, et vous pouvez thématiser en CSS.

Pourquoi ça échoue : on masque l’input avec display:none ou visibility:hidden (le retire du focus/AT). Ou on le superpose mais casse les pointer events. Ou on oublie le style de focus.

Pattern B : styliser l’input lui-même avec appearance (prudemment)

Le CSS moderne propose appearance: none dans plusieurs navigateurs, ce qui permet de restyler directement l’input natif. Cela peut être propre. Ça peut aussi exploser en forced-colors, sur des quirks de plateforme et dans les vieux navigateurs.

Mon avis : n’utilisez ceci que si votre matrice de support est moderne et que vous testez explicitement forced colors et le zoom. Sinon Pattern A est plus robuste.

Pattern C : utiliser accent-color quand vous voulez juste être à la marque

Si votre objectif est « rendre les cases bleues », pas « inventer une nouvelle case », utilisez accent-color. Il conserve le rendu natif mais change la couleur de surlignage. C’est l’option la moins risquée et la moins excitante — donc parfaite.

Règle : Plus votre case est « custom », plus elle nécessite de tests opérationnels. Traitez les contrôles personnalisés comme une dépendance de production, pas comme une fantaisie CSS.

À éviter : role=checkbox sur une div

Oui, ARIA propose role="checkbox". Non, cela ne vous donne pas la parité gratuite avec les inputs natifs. Vous vous engagez à implémenter clavier, focus, association de label, intégration formulaire, états désactivés et subtilités des lecteurs d’écran. Vous vous engagez aussi à vous tromper sur au moins une combo navigateur/AT que vous ne contrôlez pas.

Si vous devez le faire (application intégrée, pas de formulaires, contraintes extrêmes), écrivez-le comme un composant avec un SLO, des tests across AT et un plan de rollback. Sinon : n’en faites pas.

Recettes CSS : case, radio et « toggle » sans mentir

HTML de base qui scale

Cette structure est ennuyeuse. C’est voulu. Chaque option est un label enveloppant l’input et les éléments visuels. Cela crée une grande cible de clic et maintient l’association à l’épreuve des balles sans dépendre d’IDs.

cr0x@server:~$ cat controls.html
<fieldset class="choices">
  <legend>Notification settings</legend>

  <label class="choice">
    <input class="choice__input" type="checkbox" name="email_alerts">
    <span class="choice__control" aria-hidden="true"></span>
    <span class="choice__text">Email alerts</span>
  </label>

  <label class="choice">
    <input class="choice__input" type="checkbox" name="sms_alerts" disabled>
    <span class="choice__control" aria-hidden="true"></span>
    <span class="choice__text">SMS alerts (disabled)</span>
  </label>
</fieldset>

<fieldset class="choices">
  <legend>Pager escalation</legend>

  <label class="choice">
    <input class="choice__input" type="radio" name="pager" value="none">
    <span class="choice__control choice__control--radio" aria-hidden="true"></span>
    <span class="choice__text">None</span>
  </label>

  <label class="choice">
    <input class="choice__input" type="radio" name="pager" value="critical">
    <span class="choice__control choice__control--radio" aria-hidden="true"></span>
    <span class="choice__text">Critical only</span>
  </label>
</fieldset>

Remarquez le aria-hidden="true" sur le span décoratif du contrôle. L’input fournit déjà la sémantique ; nous ne voulons pas que l’ornement apparaisse dans l’arbre d’accessibilité.

CSS : masqué visuellement, pas mortel fonctionnellement

Voici la partie cruciale : « masqué visuellement » signifie toujours focalisable et toujours dans l’arbre d’accessibilité. N’utilisez pas display:none. N’utilisez pas visibility:hidden. Ce sont l’équivalent de débrancher un disque parce que la LED vous gêne.

cr0x@server:~$ cat controls.css
.choices {
  border: 1px solid #d0d7de;
  border-radius: 10px;
  padding: 12px 14px;
  margin: 14px 0;
}

.choice {
  display: grid;
  grid-template-columns: 1.4rem 1fr;
  align-items: start;
  gap: 0.6rem;
  padding: 0.45rem 0;
  cursor: pointer;
}

.choice__input {
  /* Visually hidden but still focusable */
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.choice__control {
  width: 1.25rem;
  height: 1.25rem;
  border: 2px solid #5b6472;
  border-radius: 0.35rem;
  display: inline-grid;
  place-items: center;
  background: #fff;
  box-sizing: border-box;
}

.choice__control--radio {
  border-radius: 999px;
}

.choice__text {
  color: #111;
}

/* Checked */
.choice__input:checked + .choice__control {
  border-color: #0b5fff;
  background: #0b5fff;
}

.choice__input:checked + .choice__control::after {
  content: "";
  width: 0.65rem;
  height: 0.65rem;
  background: #fff;
  border-radius: 0.18rem;
}

.choice__input:checked + .choice__control--radio::after {
  border-radius: 999px;
  width: 0.55rem;
  height: 0.55rem;
}

/* Focus */
.choice__input:focus-visible + .choice__control {
  outline: 3px solid #0b5fff;
  outline-offset: 2px;
}

/* Disabled */
.choice__input:disabled + .choice__control {
  border-color: #9aa4b2;
  background: #f1f3f5;
}

.choice__input:disabled ~ .choice__text {
  color: #667085;
}

.choice:has(.choice__input:disabled) {
  cursor: not-allowed;
}

/* Forced colors */
@media (forced-colors: active) {
  .choice__control {
    forced-color-adjust: none;
    border-color: CanvasText;
    background: Canvas;
  }
  .choice__input:checked + .choice__control {
    background: Highlight;
    border-color: Highlight;
  }
  .choice__input:checked + .choice__control::after {
    background: HighlightText;
  }
  .choice__input:focus-visible + .choice__control {
    outline-color: Highlight;
  }
}

Important : :has() est utilisé ci‑dessus pour le style du curseur. Si vous devez supporter des navigateurs qui ne le gèrent pas, supprimez cette règle et acceptez un curseur moins parfait. Ne la remplacez pas par du JS. La précision du curseur ne vaut pas le risque sémantique.

Cases indéterminées : l’état que tout le monde oublie

Indéterminé n’est pas une valeur que l’utilisateur peut basculer directement avec une case native ; c’est généralement défini par l’application quand des enfants sont partiellement sélectionnés. Vous pouvez le styliser en CSS si vous utilisez le pseudo‑classe :indeterminate.

cr0x@server:~$ cat indeterminate.css
.choice__input:indeterminate + .choice__control {
  border-color: #0b5fff;
  background: #0b5fff;
}

.choice__input:indeterminate + .choice__control::after {
  content: "";
  width: 0.7rem;
  height: 0.15rem;
  background: #fff;
  border-radius: 999px;
}

Définir indeterminate nécessite typiquement du JS (puisque HTML ne le permet pas en déclaration). Mais vous pouvez garder le comportement natif : JS définit input.indeterminate = true ; le CSS le stylise. C’est honnête.

Un mot sur les interrupteurs « toggle »

Tout le monde veut un interrupteur iOS. Mais la plupart des composants « switch » ne sont qu’une case déguisée. Cela peut convenir si vous ne mentez pas sur ce que c’est : utilisez une case, libellez-la clairement et n’en faites pas un rôle ARIA étrange sauf raison valable. L’input reste le contrôle ; le switch est une décoration.

Blague #1 Un « toggle custom » construit sur une div, c’est comme un RAID 0 d’émotions : rapide à livrer, catastrophique à faire confiance.

Tous les états à supporter (et comment ils échouent)

Coché vs décoché

Visuellement c’est la partie facile, et la plus facile à inverser par accident. J’ai vu du CSS qui affiche la coche quand c’est décoché parce que quelqu’un a inversé les sélecteurs lors d’un refactor. Si vos visuels et votre valeur réelle divergent, vous avez créé une UI qui ment.

Focus et navigation clavier

Les utilisateurs clavier ne sont pas une niche. Ils incluent les power users, les personnes avec des déficiences motrices, les installations kiosque et les ingénieurs qui aiment Tab parce que c’est plus rapide. Les points critiques :

  • Tab doit atteindre le contrôle dans un ordre prévisible.
  • Le focus doit être visible quand il est atteint.
  • Espace bascule les cases et active les radios.
  • Dans un groupe radio, les flèches naviguent les options (détails dépendants du navigateur), mais le comportement de focus doit rester sain.

Si vous cachez mal l’input, le focus disparaît. Si vous simulez l’input avec une div, vous oublierez probablement Espace ou les flèches. Si vous retirez les outlines, le focus devient une chasse au trésor.

Désactivé

Les contrôles désactivés ont besoin à la fois du désactivé sémantique (attribut disabled) et du désactivé visuel (couleurs/curseur). Ne vous contentez pas de l’assombrir. C’est juste faire paraître quelque chose désactivé alors qu’il bascule encore — l’équivalent UI d’un système de fichiers en lecture seule qui accepte toujours les écritures jusqu’à plantage.

Invalide et messages d’erreur

Les groupes de cases échouent souvent la validation (« Vous devez accepter les conditions »). Le contrôle doit supporter :invalid et/ou une classe d’état d’erreur explicite. Le message d’erreur doit être associé de façon programmatique (typiquement via aria-describedby sur l’input ou le groupe). Le CSS peut gérer le visuel ; la sémantique nécessite de la rigueur HTML.

Contraste élevé et forced colors

Si votre coche est une image de fond, forced colors l’ignorera probablement. C’est pourquoi la recette utilise bordures et fonds, plus forced-color-adjust et des couleurs système comme CanvasText. L’objectif n’est pas de sauver votre palette exacte ; c’est de sauver le sens.

Zoom, texte large et cibles tactiles

À 200 % de zoom, votre case de 12 px devient un instrument de précision. Utilisez un label englobant et un padding généreux pour que la cible tap/clic soit large. Dans les apps d’entreprise, beaucoup d’usage est sur des ordinateurs tactiles. Les petits contrôles deviennent des rapports « pourquoi ça marche pas ».

Trois mini-récits d’entreprise du terrain

Incident : la mauvaise hypothèse qui a transformé le consentement en chaos

Une équipe produit a déployé un écran de consentement redesigné : catégories de cookies, opt-ins marketing, le buffet habituel de conformité. Le nouveau design utilisait des cases stylisées personnalisées implémentées comme des divs avec handlers click. Quelqu’un a ajouté role="checkbox" et aria-checked en supposant que cela suffisait.

Ça marchait surtout à la souris. Ça marchait dans le navigateur préféré du designer. L’incident a commencé discrètement : le support client a reçu des rapports épars « je ne peux pas refuser » ou « la case revient en arrière ». Les rapports étaient incohérents. C’est le plus dangereux.

Puis le juridique est intervenu. Un utilisateur a enregistré une session : en navigation clavier, Tab contournait certains contrôles, et Espace faisait défiler la page au lieu de basculer. L’UI affichait visuellement « décoché », mais l’état en arrière-plan était « coché » parce que le handler click s’exécutait lors de clics sur le label dans des chemins étranges. Différents chemins, états différents. Une UI de consentement qui ne reflète pas de façon fiable la valeur réelle n’est pas un bug de design ; c’est un risque opérationnel avec conséquences réglementaires.

La correction fut peu glamour : retirer les contrôles en div, réintroduire des inputs natifs et les styler avec des spans frères. L’équipe a aussi ajouté un test de fumée d’accessibilité dans le CI. Pas parce qu’ils étaient devenus saints, mais parce qu’ils ne voulaient pas une autre guerre inter‑fonctionnelle autour d’une case.

Optimisation qui s’est retournée : réduction de DOM qui casse le comportement

Une autre entreprise avait un outil interne lourd en formulaires. Les plaintes de perf étaient réelles : vieux laptops, gros tableaux, beaucoup de contrôles. Quelqu’un a proposé une « optimisation » : supprimer le balisage supplémentaire pour les contrôles personnalisés en stylisant directement les inputs avec appearance:none et en retirant les labels autour d’eux. Moins de DOM, rendu plus rapide — sur le papier.

Résultat : amélioration mesurable du temps de rendu initial dans un benchmark. Puis le retour de bâton. Les cibles de clic ont rapetissé parce que les labels n’enveloppaient plus le texte. Les utilisateurs ont commencé à manquer des contrôles ; le taux d’erreur a augmenté. Le canal support s’est rempli de rapports « ça n’a pas enregistré » qui étaient en fait « je n’ai pas cliqué la petite case ».

Pire : les anneaux de focus étaient incohérents selon les navigateurs quand on stylisait l’input lui‑même. Certaines combinaisons CSS faisaient que l’indicateur de focus était coupé par des règles d’overflow dans les containers. Les utilisateurs clavier étaient effectivement aveugles. Le gain de perf a été mangé par la perte de productivité et les interruptions « c’est cassé ».

La leçon n’était pas « ne jamais optimiser le DOM ». C’était « optimisez là où ça compte ». Ils ont gardé un balisage un peu plus large (input + span contrôle + span texte) et optimisé ailleurs : virtualisation, moins de reflows et containment CSS sain. La case n’était pas le goulot. Elle était juste le bouc émissaire.

Ennuyeux mais correct : la pratique qui a sauvé un incident

Une équipe en charge de paiements maintenait un formulaire d’onboarding en plusieurs étapes. Ils avaient une politique : tout contrôle de formulaire personnalisé doit avoir un test d’acceptation uniquement clavier écrit et exécuté avant chaque release. Pas d’exceptions. Ce n’était pas glamour, et certainement pas populaire en période de rush.

Un vendredi, un designer a poussé un tweak visuel « mineur » : masquer la checkbox native avec display:none parce qu’elle « montrait encore un pixel ». Le reste du contrôle avait l’air correct. Les clics souris fonctionnaient encore parce que le click handler du label basculait un état en JS (oui, il y avait aussi du JS). Ça aurait pu être livré.

Le test d’acceptation l’a détecté en minutes : Tab ne ciblait plus la checkbox. La sortie du lecteur d’écran avait changé. L’équipe a reverté le CSS et utilisé une technique visually-hidden correcte à la place. La production n’a jamais vu la régression. Personne n’a reçu d’alerte pour un changement UI, ce qui est le meilleur type d’incident : celui qu’on n’a pas.

Cette politique semblait bureaucratique jusqu’à ce qu’elle empêche une panne à fort impact dans un funnel critique. Les pratiques ennuyeuses sont souvent juste de la fiabilité avec un clipboard.

Mode opératoire de diagnostic rapide

Quand des cases/boutons radio personnalisés « semblent cassés », ne commencez pas par retoucher les couleurs. Commencez par prouver la sémantique et le comportement. Voici une séquence rapide, adaptée à la production, qui trouve le goulot rapidement.

Première étape : prouver que l’input natif existe et est focalisable

  • Pouvez‑vous y accéder avec Tab ?
  • Est‑ce que Espace le bascule ?
  • L’anneau de focus apparaît‑t‑il quelque part visible ?

Si non : votre input est mal caché (display:none, visibility:hidden), hors écran sans style de focus, ou couvert par un autre élément.

Deuxième étape : prouver l’association label et la cible de clic

  • Cliquez sur le texte, pas sur la case. Est‑ce que ça bascule ?
  • Le tap sur mobile fonctionne‑t‑il de manière fiable ?

Si non : le label n’est pas associé, ou les événements pointer sont interceptés par votre élément décoratif.

Troisième étape : prouver la parité d’état (visuel vs réel)

  • Inspectez l’état checked de l’input dans les devtools en togglant.
  • Soumettez le formulaire et inspectez la charge envoyée.

Si l’UI montre coché mais que l’input est décoché (ou l’inverse), vous avez un « contrôle qui ment ». Arrêtez‑vous et corrigez la source de vérité : l’input doit posséder l’état.

Quatrième étape : forced colors et zoom

  • Testez forced colors (Windows) ou émulez forced colors si possible.
  • Zoomez à 200 % et vérifiez que la zone de clic fonctionne toujours.

Si échec ici : vous comptez sur des visuels non adaptatifs (images de fond, dégradés, contours fins) ou des cibles trop petites.

Cinquième étape : comportement des groupes radio

  • Confirmez que tous les radios partagent un name.
  • Parcourez les options avec les flèches et observez les règles de sélection.

Si cela échoue : les inputs ne sont pas de vrais radios, ou votre structure DOM interfère avec le focus/l’interaction.

Blague #2 Déboguer des radios personnalisées, c’est comme déboguer le DNS : ce n’est jamais le radio, jusqu’à ce que ça le soit.

Tâches pratiques : commandes, sorties, décisions

Ce sont des tâches réelles que vous pouvez exécuter sur une station de travail ou un runner CI pour diagnostiquer des « contrôles qui mentent ». Chaque tâche inclut une commande, une sortie d’exemple, ce que cela signifie et la décision suivante. L’objectif est opérationnel : réduire l’ambiguïté rapidement.

Tâche 1 : Confirmer que les inputs existent et ne sont pas en display-none

cr0x@server:~$ rg -n 'display\s*:\s*none|visibility\s*:\s*hidden' src/ styles/
src/components/Choice.css:41:  display: none;

La sortie signifie : Une feuille de style utilise display:none sur quelque chose — souvent l’input.

Décision : Remplacez par un pattern visually-hidden qui préserve focus/AT. Si la règle cible l’input, traitez‑la comme un bug Sev‑2 dans votre librairie UI.

Tâche 2 : Trouver les implémentations « checkbox » basées sur des div

cr0x@server:~$ rg -n 'role="checkbox"|role="radio"|aria-checked' src/
src/components/LegacyToggle.tsx:17: return <div role="checkbox" aria-checked={checked} ...>

La sortie signifie : Quelqu’un implémente la sémantique de la case manuellement.

Décision : Auditez le traitement clavier et le libellage. Si c’est un contrôle de formulaire, planifiez son remplacement par des inputs natifs sauf contrainte majeure.

Tâche 3 : Vérifier le groupement des radios par name

cr0x@server:~$ rg -n 'type="radio"' src/ | head
src/pages/Preferences.html:88: <input type="radio" name="pager" value="none">
src/pages/Preferences.html:94: <input type="radio" name="pager" value="critical">

La sortie signifie : Vous pouvez repérer si le name est cohérent dans le groupe.

Décision : Si les noms diffèrent, corrigez‑les. Si les noms correspondent mais le comportement est étrange, vérifiez si du JS intercepte les événements clavier ou des éléments interactifs imbriqués.

Tâche 4 : Vérifier les labels manquants

cr0x@server:~$ rg -n '<input[^>]+type="checkbox"|<input[^>]+type="radio"' src/ | head -n 20
src/pages/Checkout.html:211: <input type="checkbox" id="tos">

La sortie signifie : Les inputs existent ; il faut maintenant vérifier l’association des labels.

Décision : Confirmez qu’il y a un <label for="tos"> correspondant ou que l’input est enveloppé par un label. Sinon, ajoutez‑le. Ne « corrigez » pas avec des handlers click.

Tâche 5 : Repérer les pièges pointer-events sur éléments décoratifs

cr0x@server:~$ rg -n 'pointer-events\s*:\s*auto|pointer-events\s*:\s*none' src/styles/
src/styles/controls.css:77: .choice__control { pointer-events: auto; }

La sortie signifie : Les éléments décoratifs peuvent intercepter les clics/taps.

Décision : Généralement, mettez l’élément décoratif à pointer-events:none et laissez le label gérer l’interaction, sauf raison spécifique. Testez ensuite le tap mobile.

Tâche 6 : Lancer axe-core dans le CI (headless)

cr0x@server:~$ npx playwright test --project=chromium --grep "@a11y"
Running 6 tests using 1 worker
✓ 6 passed (18.2s)

La sortie signifie : Vos tests a11y automatisés sont passés (pour ce qu’ils couvrent).

Décision : Conservez‑les, mais n’en restez pas là. Ajoutez des tests scriptés uniquement clavier pour l’ordre de focus et les bascules ; axe ne détectera pas tout sur le « ressenti » ou les forced colors.

Tâche 7 : Valider le contraste des anneaux de focus et des états

cr0x@server:~$ node -e 'console.log("Manual check: verify focus ring color against backgrounds in design tokens")'
Manual check: verify focus ring color against backgrounds in design tokens

La sortie signifie : C’est un rappel : le contraste est en partie mesurable, en partie contextuel.

Décision : Assurez‑vous que la couleur du focus ring respecte les attentes de contraste contre les fonds clair/sombre. Si votre système a des thèmes, testez les deux.

Tâche 8 : Détecter l’usage d’images de fond pour les coches

cr0x@server:~$ rg -n 'background-image|mask-image|data:image' src/styles/
src/styles/checkbox.css:19: background-image: url("check.svg");

La sortie signifie : Les coches sont dessinées via des images/masks.

Décision : Si vous supportez forced colors, remplacez par des formes CSS (bords/fond) ou assurez‑vous que les overrides forced-colors fournissent des états visibles.

Tâche 9 : Vérifier la suppression des outlines

cr0x@server:~$ rg -n 'outline\s*:\s*none|outline\s*:\s*0' src/styles/
src/styles/reset.css:12: *:focus { outline: none; }

La sortie signifie : Un reset global tue les indicateurs de focus sur tout le site.

Décision : Retirez‑le, ou remplacez par du :focus-visible. Considérez la suppression globale d’outline comme un bug de fiabilité ; ça casse la navigation sous contrainte (et pendant les audits).

Tâche 10 : Vérifier que les formulaires soumettent les valeurs attendues

cr0x@server:~$ python3 - <<'PY'
from urllib.parse import urlencode
payload = {"email_alerts": "on", "pager": "critical"}
print(urlencode(payload))
PY
email_alerts=on&pager=critical

La sortie signifie : Une checkbox cochée soumet son name avec la valeur « on » par défaut ; les radios soumettent la value sélectionnée.

Décision : Si votre backend attend d’autres valeurs, définissez explicitement la value sur les cases ou transformez côté serveur. Ne créez pas d’état client séparé des inputs.

Tâche 11 : Vérifier que l’indéterminé n’est pas pris pour coché

cr0x@server:~$ node - <<'NODE'
console.log("Indeterminate is a UI state, not a submitted value. Checked controls submission; indeterminate does not.")
NODE
Indeterminate is a UI state, not a submitted value. Checked controls submission; indeterminate does not.

La sortie signifie : Rappel d’un bug logique fréquent : traiter indeterminate comme « vrai ».

Décision : Assurez‑vous que votre logique définit explicitement checked et indeterminate selon la sélection des enfants, et que la logique de soumission n’envoie que les checked.

Tâche 12 : Test rapide de l’ordre de focus avec un passage clavier scripté

cr0x@server:~$ npx playwright test -g "keyboard navigation"
Running 1 test using 1 worker
✓ 1 passed (4.9s)

La sortie signifie : Votre test de navigation clavier est passé. (Si vous n’en avez pas, ce test échouera parce qu’il n’existe pas. C’est le but.)

Décision : Ajoutez des assertions que Tab atteint l’input, que Espace le bascule et que l’anneau de focus est présent. Traitez‑le comme un test de régression pour une API critique.

Tâche 13 : Vérifier les styles calculés pour forced-colors (runner CI Windows)

cr0x@server:~$ node -e 'console.log("Decision point: run a Windows job to validate forced-colors snapshots; Linux/macOS runners won’t represent it.")'
Decision point: run a Windows job to validate forced-colors snapshots; Linux/macOS runners won’t represent it.

La sortie signifie : Forced colors dépend de la plateforme ; vous avez besoin du bon environnement.

Décision : Si l’accessibilité est dans le périmètre (et elle l’est), ajoutez au moins une lane CI Windows ou une étape de test Windows manuelle pour les releases impliquant des contrôles.

Tâche 14 : Détecter l’usage accidentel de tabindex sur des spans décoratifs

cr0x@server:~$ rg -n 'tabindex=' src/components/
src/components/Choice.tsx:23: <span class="choice__control" tabindex="0"></span>

La sortie signifie : Des éléments décoratifs sont rendus focalisables, ce qui perturbe l’ordre de tabulation et embrouille les lecteurs d’écran.

Décision : Retirez le tabindex des éléments non interactifs. Gardez le focus sur l’input natif. Si vous avez besoin d’une zone de focus plus large, utilisez le style et le padding du label.

Erreurs courantes : symptômes → cause → correction

Symptôme Cause racine Correction
Tab saute complètement la case/radio Input caché avec display:none ou retiré du DOM ; ou tabindex mal configuré Utiliser un pattern visually-hidden (clip/1px) et s’assurer que seul l’input est focalisable
Espace ne bascule pas ; la page défile à la place Pas un vrai input ; div avec handlers click ; gestion keydown manquante Utiliser des inputs natifs. Si vous devez utiliser ARIA, implémentez le support clavier complet (et acceptez la maintenance continue)
Cliquez sur le texte du label ne bascule pas Pas d’association de label (manque for/id, ou input non enveloppé) Envelopper l’input dans un label ou câbler correctement le for ; enlever les hacks JS de click
L’anneau de focus existe mais est invisible Outline supprimé dans le reset ; couleur de focus trop faible ; coupé par overflow Utiliser :focus-visible avec contraste suffisant ; éviter les containers qui coupent ou ajouter un outline-offset/espace
Visuel coché ne correspond pas à la valeur soumise État visuel géré séparément (toggle de classes) de l’checked de l’input Faire de l’input la seule source de vérité ; styler via des sélecteurs :checked uniquement
Mode contraste élevé affiche des cases vides Cocher dessiné avec images/masks ; couleurs surchargées par forced colors Utiliser des fonds/bordures CSS et ajouter des overrides @media (forced-colors: active) avec couleurs système
Le groupe radio permet plusieurs sélections Les inputs n’ont pas le même name ; ou ne sont pas de vrais radios Assurer un name cohérent dans le groupe ; garder les inputs radio natifs
Les utilisateurs tactiles se plaignent « difficile à cliquer » Zone de frappe petite ; seule la case est cliquable mais pas le texte ; padding trop serré Envelopper avec un label ; ajouter padding et espacement ; envisager une cible tactile minimum de 44 px
Le lecteur d’écran annonce « groupe » mais pas clairement les options Manque de fieldset/legend pour les groupes ; ou libellage incorrect Utiliser <fieldset> et <legend> pour les groupes ; s’assurer que chaque input a un label
Désactivé semble désactivé mais bascule encore Uniquement stylé comme désactivé ; attribut disabled absent Mettre disabled sur l’input ; styler les états :disabled ; supprimer les toggles JS

Listes de contrôle / plan pas à pas

Pas à pas : construire un composant case/radio fiable (pur CSS)

  1. Commencez par le HTML natif. Utilisez input + label. Pour les groupes, utilisez fieldset + legend.
  2. Décidez du niveau de personnalisation. Si accent-color suffit, arrêtez‑vous là.
  3. Choisissez Pattern A ou B. Préférez Pattern A (input masqué + sibling stylisé) pour la robustesse.
  4. Implémentez l’input visually-hidden correctement. Utilisez la technique clip/1px ; jamais display:none.
  5. Stylez les états depuis des sélecteurs. Utilisez :checked, :disabled, :focus-visible, :indeterminate.
  6. Rendez le focus indiscutable. Utilisez un outline visible avec offset. Ne comptez pas sur des ombres subtiles.
  7. Supportez forced colors. Ajoutez des overrides @media (forced-colors: active) et utilisez des couleurs système.
  8. Vérifiez la taille de la cible de clic. Wrapper label, padding et espacement doivent rendre la sélection aisée à 200 % de zoom.
  9. Testez uniquement au clavier. Tab, Shift+Tab, Espace ; navigation par flèches pour les radios.
  10. Testez au moins une chaîne avec un lecteur d’écran. Même un smoke test basique détecte les problèmes d’étiquetage évidents.
  11. Ajoutez des tests de régression. Checks Axe plus test scripté de navigation clavier.
  12. Publiez avec un plan de rollback. Si c’est un changement de design system, traitez‑le comme une mise à jour de librairie partagée.

Checklist de release (ce que j’exigerais en production)

  • Anneau de focus visible en thèmes clair et sombre
  • Passage clavier : chaque contrôle atteignable ; Espace bascule ; radios se comportent en groupe
  • Passage souris et tactile : le texte du label bascule ; pas de cibles trop petites
  • Passage forced-colors (Windows) : états coché et focus toujours distinguables
  • Passage soumission formulaire : payload correspond à l’état visuel ; les contrôles désactivés ne soumettent pas
  • Passage erreur/invalide : message d’erreur associé et visible
  • Passage indéterminé (si utilisé) : style et logique d’état confirmés
  • Pas de outline:none global dans le CSS livré

Cadre opérationnel : les contrôles personnalisés sont une infrastructure partagée. S’ils cassent, tout en aval casse : onboarding, checkout, paramètres, consentement. Traitez‑les comme un service cœur.

FAQ

1) Puis‑je cacher l’input avec opacity: 0 au lieu de la technique clip ?

Parfois. Mais les inputs avec opacity:0 occupent encore le layout et peuvent créer des zones de clic étranges. Le pattern clip/1px visually-hidden est plus prévisible et largement utilisé pour l’accessibilité.

2) Le display:none est‑il jamais acceptable pour l’input ?

Pas si cet input est le contrôle interactif. display:none le retire de l’arbre d’accessibilité et de la navigation clavier. Si l’input est purement redondant (rare), peut‑être — mais dans ce cas vous ne devriez pas l’avoir.

3) Dois‑je utiliser role="switch" pour les interrupteurs ?

Seulement si vous avez vraiment besoin de la sémantique switch et que vous savez ce que vous faites. Pour beaucoup de produits, une case libellée « Activer X » est plus claire et plus compatible. Le rôle switch augmente la charge de tests across AT.

4) Les pseudo‑éléments sont‑ils sûrs pour les coches ?

Oui, s’ils sont purement décoratifs et pilotés par l’état de l’input (:checked + span::after). En forced colors, vous devrez peut‑être des overrides pour que le pseudo‑élément reste visible.

5) Qu’en est‑il d’utiliser un SVG pour la coche ?

Le SVG convient en décoration. Ne l’utilisez pas pour remplacer le contrôle sémantique. Vérifiez aussi le comportement forced-colors ; certains remplissages SVG peuvent ne pas s’adapter sauf si vous les gérez.

6) Ai‑je besoin d’attributs ARIA sur des inputs natifs ?

Généralement non. Les inputs natifs exposent déjà coché/décoché/désactivé. Utilisez ARIA pour décrire des erreurs (aria-describedby) ou pour le groupement si vous ne pouvez pas utiliser fieldset/legend. Évitez les ARIA redondantes qui peuvent embrouiller les AT.

7) Pourquoi recommander :focus-visible plutôt que :focus ?

:focus-visible montre généralement le style de focus pour les interactions clavier et autres interactions non pointer sans afficher des anneaux lors des clics souris. C’est un compromis par défaut meilleur. Vous pouvez néanmoins retomber sur :focus si nécessaire.

8) Comment gérer l’obligation d’une case requise accessiblement ?

Marquez la case comme required (required) ou validez au niveau du groupe. Fournissez un message d’erreur adjacent au contrôle et liez‑le avec aria-describedby. Visuellement, styler :invalid ou appliquer une classe d’erreur sur le wrapper.

9) Quelle est la matrice de test minimale pour les contrôles personnalisés ?

Au minimum : un navigateur basé Chromium, un Firefox, un Safari (si vous le supportez), un passage uniquement clavier, un passage lecteur d’écran, et un contrôle forced-colors Windows si l’accessibilité est dans le périmètre (elle l’est).

10) Si j’utilise accent-color, ai‑je encore besoin de tout ça ?

Vous avez besoin de moins de complexité CSS, mais vous devez toujours vous assurer du libellage, du groupement et de la justesse du focus. accent-color réduit la surface d’erreurs pour forced-colors et les mismatches d’état, c’est pourquoi c’est souvent le meilleur premier choix.

Prochaines étapes réalisables cette semaine

Si vous gérez des systèmes en production, vous connaissez déjà ce pattern : les échecs les plus dangereux viennent d’interfaces qui paraissent correctes alors qu’elles font le contraire. Les cases/boutons radio personnalisés sont exactement ce type de risque quand on les construit sans soin.

Étapes pratiques :

  1. Inventoriez vos contrôles. Grepez pour role="checkbox", resets d’outline et inputs cachés.
  2. Standardisez sur un pattern honnête. Préférez input natif + label wrapper + sibling styling. Écrivez‑le une fois, réutilisez‑le partout.
  3. Ajoutez un test de navigation clavier. Faites‑le échouer bruyamment sur régressions.
  4. Exécutez un contrôle forced-colors avant de release des redesigns visuels. Si vous ne pouvez pas l’automatiser, faites‑en une étape de release.
  5. Documentez les modes d’échec. Mettez « ne pas utiliser display:none sur les inputs » dans les règles du design system, à côté de l’usage des tokens.

Faites cela, et vos contrôles personnalisés arrêteront de mentir. Ils arrêteront aussi de générer la forme de chaos peu bruyant mais coûteux qui ruine les sprints et alourdit silencieusement l’équipe support. L’ennuyeux, c’est bien. L’ennuyeux, c’est fiable.

Vous n’avez pas besoin d’héroïsme pour rendre les cases accessibles. Vous avez besoin de sémantique native, de styles honnêtes et de tests qui reflètent comment les vraies personnes utilisent l’UI.

← Précédent
MySQL vs PostgreSQL sur un VPS 1 Go : ce qui est réellement utilisable (et les réglages qui le rendent possible)
Suivant →
Chiffrement ZFS : sécurité forte sans sacrifier les performances

Laisser un commentaire