Un anti-pattern ARIA est une pratique qui semble correcte, car elle utilise les bons attributs, mais produit un résultat incorrect, voire pire qu’une absence d’ARIA. La plupart viennent d’une compréhension partielle de ce qu’ARIA communique réellement aux technologies d’assistance (AT).
Cet article documente les dix erreurs les plus fréquemment rencontrées en audit. Pour chacune : le code incorrect, ce que NVDA annonce réellement, et la correction.
Anti-pattern 1 : L’ARIA cargo cult
Description : copier-coller d’attributs ARIA vus sur un autre site ou dans une documentation, sans comprendre leur sémantique dans le contexte précis. L’ARIA est présent, mais incohérent avec le composant réel.
Exemple : aria-pressed sur un lien de navigation
<!-- ✗ aria-pressed n'a de sens que sur un toggle button persistant -->
<a href="/actualites" role="button" aria-pressed="false">
Actualités
</a>
→ NVDA : "Actualités, bouton bascule non activé, lien"
aria-pressed signale un état persistant (activé / désactivé). Sur un lien de navigation, ça n’a aucun sens, la page n’est pas “activée” ou “désactivée”. L’utilisateur AT est induit en erreur.
Correction :
<!-- ✓ Lien de navigation : juste un <a>, avec aria-current si page active -->
<a href="/actualites" aria-current="page">
Actualités
</a>
Exemple : aria-live sur chaque div modifié
<!-- ✗ aria-live sur un composant qui change rarement et de façon prévisible -->
<div aria-live="polite" class="carte-produit">
<h2>Chaise ergonomique</h2>
<p class="prix">249 €</p>
</div>
Le prix change si l’utilisateur sélectionne une variante, mais aria-live sur toute la carte annonce chaque changement, y compris les mises à jour de titre et de description. L’utilisateur AT reçoit un flux d’annonces non sollicitées.
Correction : aria-live uniquement sur la zone qui change et doit être annoncée.
<div class="carte-produit">
<h2>Chaise ergonomique</h2>
<!-- Seul le prix est une live region -->
<p class="prix" aria-live="polite">249 €</p>
</div>
Critère RGAA : 7.1 .
Anti-pattern 2 : La surcharge ARIA
Description : ajouter des attributs ARIA redondants avec la sémantique HTML native. Ça n’améliore rien, mais ça alourdit le code et signale une incompréhension de la relation HTML/ARIA.
<!-- ✗ Tout redondant — HTML natif fait déjà tout ça -->
<nav role="navigation" aria-label="navigation">
<ul role="list">
<li role="listitem">
<a href="/" role="link">Accueil</a>
</li>
</ul>
</nav>
<h2 role="heading" aria-level="2">Nos services</h2>
<button type="button" role="button" aria-disabled="false">
Envoyer
</button>
<input type="checkbox" role="checkbox" aria-checked="false">
Ces redondances ne créent pas d’erreur fonctionnelle, mais révèlent une méconnaissance des éléments HTML sémantiques. En audit, elles indiquent souvent qu’il y a d’autres problèmes ARIA plus graves ailleurs dans le code.
Correction : supprimer les attributs redondants. La règle 1 de WAI-ARIA s’applique : ne pas utiliser ARIA si HTML suffit.
<!-- ✓ HTML natif — aucun ARIA nécessaire -->
<nav aria-label="Navigation principale">
<ul>
<li><a href="/">Accueil</a></li>
</ul>
</nav>
<h2>Nos services</h2>
<button>Envoyer</button> <!-- hors form : type="button" implicite -->
<input type="checkbox">
type="button" est implicite hors <form>. Le préciser explicitement est une convention défensive utile si le bouton risque d’être déplacé dans un formulaire, pas une exigence RGAA.Exception légitime : role="list" sur <ul> avec list-style: none : certains navigateurs (Safari) suppriment le rôle liste quand les puces sont masquées en CSS.
Critère RGAA : 8.9 .
Anti-pattern 3 : aria-hidden sur des éléments focusables
Description : appliquer aria-hidden="true" sur un élément interactif ou sur un ancêtre contenant des éléments interactifs.
<!-- ✗ Catastrophe 1 — bouton focusable masqué aux AT -->
<button aria-hidden="true" onclick="fermerModal()">
<svg><!-- icône × --></svg>
</button>
<!-- ✗ Catastrophe 2 — navigation entière masquée, liens toujours focusables -->
<nav aria-hidden="true">
<ul>
<li><a href="/">Accueil</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
<!-- ✗ Catastrophe 3 — modal accessible mais fond non masqué -->
<main>
<h1>Contenu principal</h1>
<a href="/infos">En savoir plus</a>
<!-- Le focus peut atteindre ce lien pendant que la modale est ouverte -->
</main>
<div role="dialog" aria-modal="true">...</div>
Dans le cas 1, l’utilisateur clavier atteint le bouton (Tab le sélectionne), mais le lecteur d’écran n’annonce rien. Piège silencieux : l’utilisateur est bloqué sans comprendre pourquoi.
Dans le cas 2, les liens sont toujours dans l’ordre de tabulation mais muets pour les AT.
Ce que NVDA annonce dans le cas 1 :
[Tab sur le bouton masqué]
→ [silence] — l'élément reçoit le focus mais rien n'est annoncé
Correction :
<!-- ✓ Cas 1 — aria-hidden uniquement sur l'icône décorative, pas sur le bouton -->
<button onclick="fermerModal()">
<svg aria-hidden="true" focusable="false"><!-- icône × --></svg>
<span class="sr-only">Fermer</span>
</button>
<!-- ✓ Cas 2 — si la navigation doit être masquée aux AT, utiliser display:none ou hidden -->
<nav hidden>...</nav>
<!-- ✓ Cas 3 — pendant l'ouverture de la modale, rendre le fond inerte -->
<main inert>...</main>
<div role="dialog" aria-modal="true">...</div>
Règle absolue : aria-hidden="true" ne doit jamais être appliqué sur un élément avec tabindex ≥ 0, sur <a href>, <button>, <input>, <select>, <textarea>, ni sur aucun ancêtre de ces éléments.
Anti-pattern 4 : role="menu" sur la navigation principale
Description : utiliser role="menu" et role="menuitem" pour le menu de navigation d’un site. C’est l’anti-pattern le plus répandu sur les sites WordPress avec des thèmes personnalisés.
<!-- ✗ Navigation principale avec rôles de menu applicatif -->
<nav>
<ul role="menu">
<li role="menuitem"><a href="/">Accueil</a></li>
<li role="menuitem"><a href="/mairie">Mairie</a></li>
<li role="presentation">
<a role="menuitem" href="/services">Services</a>
<ul role="menu">
<li role="menuitem"><a href="/services/urbanisme">Urbanisme</a></li>
</ul>
</li>
</ul>
</nav>
→ NVDA (mode navigation) : annonce "menu", l'utilisateur s'attend à naviguer aux flèches
→ Flèche bas : aucun comportement (les flèches ne fonctionnent que si JavaScript implémente le pattern menu)
→ Tab : navigue entre les items mais NVDA annonce "menuitem", confusion totale
role="menu" est réservé aux menus d’application (Fichier, Édition, Affichage dans un logiciel). Il impose un comportement clavier très spécifique (navigation aux flèches, pas à Tab) que les utilisateurs n’attendent pas sur une navigation de site.
Si le JavaScript n’implémente pas ce comportement, l’utilisateur clavier se retrouve avec un composant qui promet une navigation aux flèches mais ne la fournit pas.
Correction :
<!-- ✓ Navigation de site : pas de rôle ARIA sur ul/li -->
<nav aria-label="Navigation principale">
<ul>
<li><a href="/" aria-current="page">Accueil</a></li>
<li><a href="/mairie">Mairie</a></li>
<li>
<a href="/services"
aria-haspopup="true"
aria-expanded="false"
aria-controls="sous-menu-services">
Services
</a>
<ul id="sous-menu-services">
<li><a href="/services/urbanisme">Urbanisme</a></li>
</ul>
</li>
</ul>
</nav>
Critères RGAA : 7.1 , 7.3 , 9.2 .
Anti-pattern 5 : Live regions mal utilisées
Trois erreurs distinctes, souvent combinées.
5a : Création et injection simultanées
<!-- ✗ La zone n'existait pas avant — les AT manquent l'annonce -->
<script>
const alerte = document.createElement('div');
alerte.setAttribute('role', 'alert');
alerte.textContent = 'Formulaire envoyé avec succès.';
document.body.appendChild(alerte); // créé ET rempli en même temps
</script>
La plupart des lecteurs d’écran surveillent les live regions existantes. Une zone créée et remplie simultanément n’est pas surveillée au moment de l’injection : le message est manqué.
Correction :
<!-- ✓ Zone pré-existante dans le HTML, vide au chargement -->
<div role="status" id="confirmation" aria-live="polite"></div>
<script>
// Vider puis remplir pour forcer la relecture
const zone = document.getElementById('confirmation');
zone.textContent = '';
requestAnimationFrame(() => {
zone.textContent = 'Formulaire envoyé avec succès.';
});
</script>
5b : role="alert" ou aria-live="assertive" sur tout
<!-- ✗ Assertive sur une confirmation non urgente -->
<div role="alert">
Vos préférences ont été enregistrées.
</div>
<!-- ✗ Assertive sur un compteur de résultats -->
<div aria-live="assertive" id="nb-resultats">
42 résultats trouvés
</div>
role="alert" et aria-live="assertive" interrompent la lecture en cours quelle qu’elle soit. Utilisés pour des confirmations banales, ils perturbent l’expérience AT sans justification. L’utilisateur qui était en train de lire un paragraphe se retrouve interrompu pour entendre “Vos préférences ont été enregistrées.”
Correction :
<!-- ✓ Polite pour les confirmations non urgentes -->
<div role="status" aria-live="polite">
Vos préférences ont été enregistrées.
</div>
<!-- ✓ Réserver assertive/alert aux erreurs et urgences -->
<div role="alert">
Erreur : impossible de se connecter au serveur.
</div>
5c : Live region sur un compteur haute fréquence
<!-- ✗ Le compteur se met à jour toutes les secondes -->
<div aria-live="polite" role="timer" id="chrono">01:23</div>
NVDA annonce “01:23” toutes les secondes : flux ininterrompu d’annonces qui rend la page inutilisable.
Correction :
<!-- ✓ aria-live="off" sur les éléments à haute fréquence de mise à jour -->
<div aria-live="off" role="timer" id="chrono">01:23</div>
<!-- L'utilisateur peut lire la valeur quand il navigue dessus, sans annonce automatique -->
Critère RGAA : 7.5 .
Anti-pattern 6 : aria-label qui contredit le texte visible
Description : utiliser aria-label avec une valeur différente du texte visible. Crée une discordance entre ce que voit l’utilisateur voyant et ce qu’entend l’utilisateur AT. Viole aussi WCAG 2.5.3 (Label in Name).
<!-- ✗ aria-label différent du texte visible -->
<button aria-label="Fermer la fenêtre de dialogue">
Annuler
</button>
<!-- ✗ aria-label décrivant l'apparence -->
<button aria-label="Bouton bleu arrondi">
Envoyer
</button>
<!-- ✗ aria-label vide — lien sans intitulé -->
<a href="/contact" aria-label="">
Nous contacter
</a>
Dans le premier cas, l’utilisateur voyant lit “Annuler” mais l’utilisateur AT entend “Fermer la fenêtre de dialogue”. Un utilisateur vocal (Dragon Naturally Speaking) dit “cliquer Annuler”, mais la commande échoue car le bouton s’appelle “Fermer la fenêtre de dialogue” pour Dragon.
Ce que NVDA annonce :
[Tab sur le bouton]
→ "Fermer la fenêtre de dialogue, bouton"
(le texte visible "Annuler" est complètement écrasé)
Correction :
<!-- ✓ aria-label cohérent avec le texte visible, ou absent si le texte suffit -->
<button>Annuler</button>
<!-- ✓ Si le contexte doit être précisé, l'inclure dans le texte visible -->
<button>
Annuler
<span class="sr-only">la réservation en cours</span>
</button>
<!-- ✓ aria-label uniquement quand il n'y a pas de texte visible -->
<button aria-label="Fermer">
<svg aria-hidden="true" focusable="false"><!-- icône × --></svg>
</button>
Cas particulier d’aria-label en complément : si tu dois enrichir le label pour le rendre explicite hors contexte, inclure le texte visible dans aria-label :
<!-- Le texte visible est "En savoir plus" — insuffisant hors contexte -->
<!-- aria-label reprend et enrichit le texte visible -->
<a href="/mairie/conseil" aria-label="En savoir plus sur le conseil municipal">
En savoir plus
</a>
Critères RGAA : 6.1 , 6.2 , 11.1 , 11.2 .
Anti-pattern 7 : tabindex positif
Description : utiliser des valeurs positives de tabindex (tabindex="1", tabindex="2"…) pour forcer un ordre de navigation. En théorie ça semble contrôler l’ordre — en pratique ça le brise.
<!-- ✗ tabindex positifs — ordre de tabulation chaotique -->
<header>
<a href="/" tabindex="1">
<img src="logo.png" alt="Accueil">
</a>
<nav tabindex="2">
<a href="/mairie">Mairie</a>
<a href="/contact">Contact</a>
</nav>
</header>
<main>
<h1>Bienvenue</h1>
<a href="/actualites" tabindex="3">Dernières actualités</a>
<p>Texte de présentation avec <a href="/histoire">un lien</a>.</p>
</main>
L’ordre de tabulation avec ces tabindex :
- Logo (tabindex=“1”)
- Le
<nav>lui-même (tabindex=“2”), pas ses liens enfants - Lien “Dernières actualités” (tabindex=“3”)
- Puis tous les éléments sans tabindex, dans l’ordre du DOM : “Mairie”, “Contact”, “un lien”
Le lien “un lien” dans le texte de présentation arrive après les actualités mais avant les liens de navigation — complètement contre-intuitif.
Règle : tabindex positif crée un ordre secondaire qui est toujours parcouru avant les éléments naturels (tabindex=“0” ou sans tabindex). Dès qu’un seul élément a tabindex="1", il passe en premier, même s’il est en bas de page.
Correction : ne jamais utiliser de tabindex positif. Si l’ordre de tabulation est mauvais, corriger l’ordre du DOM.
<!-- ✓ Pas de tabindex positif — ordre naturel du DOM -->
<!-- tabindex="0" uniquement pour rendre focusable un élément non interactif -->
<!-- tabindex="-1" uniquement pour retirer du flux de tabulation -->
Critère RGAA : 12.8 .
Anti-pattern 8 : Confusion bouton / lien
Description : utiliser le mauvais élément HTML pour l’action, ou patcher avec ARIA au lieu de corriger la structure.
<!-- ✗ Lien utilisé comme bouton d'action -->
<a href="#" onclick="soumettreFormulaire()">
Envoyer
</a>
<!-- ✗ Bouton utilisé pour naviguer -->
<button onclick="window.location.href='/contact'">
Nous contacter
</button>
<!-- ✗ Lien patché avec role="button" -->
<a href="/telecharger-pdf" role="button">
Télécharger le document
</a>
La règle sémantique :
<a href>: navigation vers une URL, une ancre, ou un téléchargement. Activé avecEntréeuniquement.<button>: déclenchement d’une action JavaScript. Activé avecEntréeETEspace.
Quand un lien a role="button", NVDA annonce “bouton”, l’utilisateur presse Espace pour l’activer (comportement attendu d’un bouton). Mais Espace sur un <a> fait défiler la page, pas activer le lien. L’utilisateur AT ne peut pas activer le “bouton”.
Ce que NVDA annonce et ce qui se passe :
[Tab sur <a role="button">Télécharger le document</a>]
→ "Télécharger le document, bouton" ← NVDA dit "bouton"
[Espace] → la page défile ← comportement de lien, pas de bouton
[Entrée] → navigation vers /telecharger-pdf ← seule activation possible
Correction :
<!-- ✓ Lien pour navigation et téléchargement -->
<a href="/telecharger-pdf" download>
Télécharger le document
<span class="sr-only">(PDF, 2 Mo)</span>
</a>
<!-- ✓ Bouton pour action JS -->
<button type="button" onclick="soumettreFormulaire()">
Envoyer
</button>
<!-- ✓ Si la destination est une URL ET que c'est une action principale -->
<!-- Utiliser un lien stylisé en bouton — sans role="button" -->
<a href="/contact" class="btn-primaire">
Nous contacter
</a>
Critères RGAA : 6.1 , 7.1 , 7.3 .
Anti-pattern 9 : aria-required sans gestion d’erreur
Description : ajouter aria-required="true" pour signaler les champs obligatoires, mais ne pas implémenter aria-invalid et aria-errormessage lors de la validation. L’utilisateur est prévenu de l’obligation mais pas de l’erreur.
<!-- ✗ aria-required présent mais validation incomplète -->
<label for="email">Email *</label>
<input
type="email"
id="email"
aria-required="true"
>
<!-- Après soumission avec champ vide : aucun feedback AT -->
[Soumission avec champ vide]
→ [visuellement] un message rouge apparaît sous le champ
→ [NVDA] ... silence. Le champ n'est pas annoncé comme invalide.
Correction : les trois attributs vont toujours ensemble lors d’une erreur :
<label for="email">Email <span aria-hidden="true">*</span></label>
<input
type="email"
id="email"
aria-required="true"
aria-invalid="true"
aria-describedby="erreur-email"
>
<p id="erreur-email" role="alert">
Saisissez une adresse email valide (ex. : nom@exemple.fr).
</p>
[Focus sur le champ après soumission]
→ "Email, zone de saisie, invalide, requis, Saisissez une adresse email valide…"
[role="alert" annonce immédiatement]
→ "Saisissez une adresse email valide (ex. : nom@exemple.fr)."
Critères RGAA : 11.9 , 11.10 , 11.11 .
Anti-pattern 10 : placeholder comme seul label
Description : utiliser l’attribut placeholder comme unique étiquette visible d’un champ, sans <label> associé. Le placeholder disparaît dès la saisie — l’utilisateur perd l’information sur ce qu’il est en train de remplir.
<!-- ✗ Placeholder comme seul label -->
<input type="text" placeholder="Votre nom">
<input type="email" placeholder="Votre adresse email">
<input type="tel" placeholder="Votre téléphone">
<!-- ✗ aria-label avec la même valeur que placeholder — cache le placeholder -->
<input type="text" placeholder="Votre nom" aria-label="Votre nom">
Problèmes cumulés :
- Le placeholder disparaît à la saisie : l’utilisateur oublie ce qu’il remplissait
- Le contraste du placeholder est souvent insuffisant (texte grisé sur fond blanc)
- Certains lecteurs d’écran n’annoncent pas le placeholder comme label
- La traduction automatique ne traduit pas toujours les
placeholder
Ce que NVDA annonce selon la configuration :
[Avec <input placeholder="Votre nom"> sans label]
→ Selon NVDA : "Votre nom, zone de saisie" (si le placeholder est lu)
ou : "Zone de saisie" (si le placeholder est ignoré selon version NVDA)
Correction :
<!-- ✓ Label visible + placeholder complémentaire -->
<div class="champ">
<label for="nom">Nom</label>
<input
type="text"
id="nom"
placeholder="ex. : Dupont"
autocomplete="family-name"
>
</div>
<!-- ✓ Si la maquette impose de cacher le label visuellement -->
<div class="champ">
<label for="nom" class="sr-only">Nom</label>
<input type="text" id="nom" placeholder="Nom" autocomplete="family-name">
</div>
Le placeholder a un rôle d’exemple ou d’indication de format, jamais de label.
Récapitulatif : Les 10 anti-patterns en un coup d’œil
| # | Anti-pattern | Signal d’alerte en audit | Critères |
|---|---|---|---|
| 1 | ARIA cargo cult | aria-pressed sur des liens, aria-live partout | 7.1 |
| 2 | Surcharge ARIA | <nav role="navigation">, <h2 role="heading"> | 8.9 |
| 3 | aria-hidden sur focusable | <button aria-hidden="true">, <nav aria-hidden="true"><a> | 7.1, 7.3 |
| 4 | role="menu" sur navigation | <ul role="menu"> dans <nav> | 7.1, 7.3, 9.2 |
| 5 | Live region mal utilisée | Zone créée et remplie simultanément, role="alert" partout | 7.5 |
| 6 | aria-label qui contredit | Label différent du texte visible, aria-label="" | 6.1, 6.2 |
| 7 | tabindex positif | tabindex="1", tabindex="2" dans le HTML | 12.8 |
| 8 | Bouton vs lien | <a role="button">, <button onclick="location.href"> | 6.1, 7.1, 7.3 |
| 9 | aria-required seul | aria-required sans aria-invalid ni message d’erreur | 11.9, 11.10, 11.11 |
| 10 | Placeholder comme label | <input placeholder="Nom"> sans <label> | 11.1, 11.2 |
Comment les détecter en audit
Automatiquement (axe DevTools, WAVE) :
- Anti-patterns 3, 6 (aria-label vide), 10 : détectés de façon fiable
- Anti-pattern 7 : partiellement détecté
Manuellement obligatoire :
- Anti-patterns 1, 2, 4, 5, 8 : nécessitent de lire le code et tester au clavier
- Anti-pattern 9 : tester la soumission du formulaire avec des champs vides
En inspectant l’arbre d’accessibilité (DevTools → onglet Accessibilité) :
- Vérifier le nom calculé de chaque élément interactif
- Vérifier le rôle calculé (pas le rôle ARIA déclaré, le rôle réel après calcul)
- Vérifier les états (expanded, selected, checked, invalid…)