Maîtrisez les attributs ARIA essentiels : aria-label, aria-describedby, aria-hidden et rôles ARIA pour rendre votre site accessible aux lecteurs d'écran.
Introduction à ARIA et WCAG
ARIA (Accessible Rich Internet Applications) est une spécification W3C qui ajoute des attributs HTML décrivant le rôle, l'état et les propriétés des éléments dynamiques aux technologies d'assistance (lecteurs d'écran, plages braille, commandes vocales). Elle comble les lacunes du HTML sémantique pour les composants interactifs modernes — accordéons, modales, sliders, comboboxes — qui n'ont pas d'équivalent natif.
Les trois piliers de WCAG 2.2
| Principe | Description | Exemples concrets |
|---|---|---|
| Perceptible | L'info et l'UI doivent être présentables à tous | Alternatives textuelles, sous-titres, contraste 4.5:1 |
| Utilisable | Navigation et interaction fonctionnelles | Clavier seul, pas de piège de focus, skip links |
| Compréhensible | L'info et l'opération doivent être compréhensibles | Labels clairs, gestion des erreurs, langue déclarée |
| Robuste | Interprétable par les technologies d'assistance | HTML valide, ARIA correct, rôles cohérents |
<button> est toujours préférable à <div role="button"> car il inclut nativement le focus, les événements clavier et les styles des agents utilisateurs.
Cadre légal en France
L'article 47 de la loi Handicap de 2005 et son décret d'application de 2019 imposent le respect du RGAA (Référentiel Général d'Amélioration de l'Accessibilité) aux sites publics. Niveau cible : WCAG 2.1 niveau AA. En 2026, les grandes entreprises privées (CA > 250M€) sont également soumises à cette obligation via la transposition de la directive européenne.
Les attributs ARIA essentiels
Les attributs ARIA se divisent en trois catégories : les propriétés (valeur stable : aria-label, aria-describedby), les états (valeur qui change : aria-expanded, aria-checked) et les relations (liens entre éléments : aria-controls, aria-owns).
aria-label et aria-labelledby
aria-label fournit un label texte direct. aria-labelledby référence un élément existant par son ID — préférez aria-labelledby quand le texte du label est déjà visible dans le DOM.
<!-- aria-label: label invisible direct -->
<button aria-label="Fermer la fenêtre de confirmation">
<svg aria-hidden="true" width="16" height="16">...</svg>
</button>
<!-- aria-labelledby: référence un titre existant (meilleur pour les modales) -->
<div role="dialog" aria-labelledby="modal-title" aria-modal="true">
<h2 id="modal-title">Confirmer la suppression</h2>
<p>Cette action est irréversible.</p>
<button>Annuler</button>
<button>Supprimer</button>
</div>
<!-- Combinaison: aria-labelledby + aria-describedby pour plus de contexte -->
<div role="dialog"
aria-labelledby="del-title"
aria-describedby="del-desc"
aria-modal="true">
<h2 id="del-title">Supprimer le fichier</h2>
<p id="del-desc">Vous supprimez "rapport-2026.pdf". Impossible d'annuler.</p>
</div>
aria-describedby
Lie un champ à sa description ou ses contraintes. Le lecteur d'écran lit le label puis la description.
<div class="form-group">
<label for="password">Mot de passe</label>
<input
id="password"
type="password"
aria-describedby="pwd-req pwd-strength"
aria-required="true"
/>
<span id="pwd-req">Minimum 12 caractères, une majuscule, un chiffre.</span>
<span id="pwd-strength" role="status">Force : Forte</span>
</div>
aria-hidden
Masque un élément et tous ses enfants de l'arbre d'accessibilité. À utiliser uniquement pour les éléments purement décoratifs.
<!-- Icône décorative: masquer du lecteur d'écran -->
<span class="icon-star" aria-hidden="true"></span>
<span>Favoris</span> <!-- texte visible lisible par le lecteur -->
<!-- Piège courant: ne jamais cacher le contenu focusable! -->
<!-- MAUVAIS: le bouton est caché visuellement mais reste focusable -->
<div aria-hidden="true">
<button>Ce bouton est accessible au clavier mais invisible à l'IA</button>
</div>
<!-- BON: si caché, vraiment caché (visibility:hidden ou display:none) -->
<div aria-hidden="true" style="display:none">
<button tabindex="-1">...</button>
</div>
aria-expanded, aria-controls, aria-selected
<!-- Accordéon accessible -->
<button
id="faq-trigger-1"
aria-expanded="false"
aria-controls="faq-panel-1"
>
Qu'est-ce qu'ARIA ?
</button>
<div id="faq-panel-1" role="region" aria-labelledby="faq-trigger-1" hidden>
<p>ARIA est une spécification W3C...</p>
</div>
<!-- Onglets accessibles (pattern WAI-ARIA Tabs) -->
<div role="tablist" aria-label="Catégories">
<button role="tab" aria-selected="true" aria-controls="panel-front" id="tab-front">Front-end</button>
<button role="tab" aria-selected="false" aria-controls="panel-back" id="tab-back" tabindex="-1">Back-end</button>
</div>
<div role="tabpanel" id="panel-front" aria-labelledby="tab-front">...</div>
<div role="tabpanel" id="panel-back" aria-labelledby="tab-back" hidden>...</div>
Les rôles ARIA complets
Les rôles ARIA sont regroupés en catégories: widgets interactifs, structure de page, et rôles de repère (landmarks). Les landmarks permettent aux utilisateurs de lecteurs d'écran de naviguer rapidement entre les grandes zones de la page.
Rôles de repère (Landmarks)
| Élément HTML | Rôle implicite | Quand utiliser |
|---|---|---|
<header> |
banner |
En-tête principal du site (hors nested) |
<nav> |
navigation |
Groupes de liens de navigation |
<main> |
main |
Contenu principal (unique par page) |
<aside> |
complementary |
Contenu secondaire (sidebar, notes) |
<footer> |
contentinfo |
Pied de page principal |
<search> |
search |
Formulaire de recherche principal |
Rôles de widgets interactifs
| Rôle | Équivalent HTML | Attributs ARIA requis |
|---|---|---|
button |
<button> |
aria-pressed (si toggle) |
checkbox |
<input type="checkbox"> |
aria-checked (true/false/mixed) |
combobox |
<select> |
aria-expanded, aria-autocomplete |
dialog |
Modal HTML (<dialog>) |
aria-modal, aria-labelledby |
listbox |
<select multiple> |
aria-multiselectable, aria-required |
slider |
<input type="range"> |
aria-valuenow, aria-valuemin, aria-valuemax |
tooltip |
Pas d'équivalent | aria-describedby sur le déclencheur |
Gestion du focus et navigation clavier
La navigation au clavier est le critère le plus fréquemment raté en audit d'accessibilité. Tout élément interactif doit être atteignable avec Tab, actionnable avec Entrée/Espace, et le focus doit toujours rester visible.
Skip link: contourner la navigation
<!-- Premier élément du <body>: permet de sauter la nav -->
<a href="#main-content" class="skip-link">
Aller au contenu principal
</a>
<nav>...longue navigation...</nav>
<main id="main-content">...</main>
/* CSS: visible uniquement au focus, invisible sinon */
.skip-link {
position: absolute;
top: -100%;
left: 8px;
padding: 8px 16px;
background: #0066cc;
color: white;
font-weight: bold;
z-index: 9999;
border-radius: 0 0 4px 4px;
text-decoration: none;
}
.skip-link:focus {
top: 0; /* visible quand focusé */
}
Piège de focus dans les modales
// Implémenter le piège de focus (focus trap) dans une modale
// Tous les éléments focusables à l'intérieur du dialog
function trapFocus(dialogElement) {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
const focusableElements = dialogElement.querySelectorAll(focusableSelectors);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
// Déplacer le focus vers le premier élément à l'ouverture
firstFocusable.focus();
dialogElement.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
// Shift+Tab: si on est sur le premier, aller au dernier
if (document.activeElement === firstFocusable) {
event.preventDefault();
lastFocusable.focus();
}
} else {
// Tab: si on est sur le dernier, aller au premier
if (document.activeElement === lastFocusable) {
event.preventDefault();
firstFocusable.focus();
}
}
});
}
// Fermer la modale avec Échap et restaurer le focus
function openModal(trigger, modal) {
const previousFocus = document.activeElement; // sauvegarder le focus
modal.removeAttribute('hidden');
modal.setAttribute('aria-hidden', 'false');
trapFocus(modal);
document.addEventListener('keydown', function onEscape(e) {
if (e.key === 'Escape') {
closeModal(modal, previousFocus);
document.removeEventListener('keydown', onEscape);
}
});
}
function closeModal(modal, restoreTo) {
modal.setAttribute('hidden', '');
modal.setAttribute('aria-hidden', 'true');
restoreTo.focus(); // restaurer le focus sur le déclencheur
}
tabindex: règles essentielles
tabindex="0": rend un élément focusable dans l'ordre naturel du DOM. Utilisez-le sur les éléments interactifs custom (<div role="button">).tabindex="-1": focusable programmatiquement mais pas avec Tab. Idéal pour les éléments de modales cachées ou les destinations de skip links.tabindex="1+": à éviter absolument — crée un ordre de focus artificiel difficile à maintenir.
Régions ARIA live et annonces dynamiques
Les régions aria-live permettent d'annoncer des mises à jour dynamiques (résultats de recherche, messages de validation, notifications) aux lecteurs d'écran sans déplacer le focus.
Les trois valeurs de aria-live
<!-- polite: annonce quand l'utilisateur est inactif (non urgent) -->
<div aria-live="polite" aria-atomic="true" id="search-status">
<!-- Mis à jour dynamiquement: "15 résultats trouvés pour Angular" -->
</div>
<!-- assertive: interrompt immédiatement (erreurs critiques) -->
<div role="alert" aria-live="assertive" aria-atomic="true" id="error-msg">
<!-- Mis à jour: "Erreur: carte de crédit refusée" -->
</div>
<!-- off: désactiver les annonces (par défaut) -->
<div aria-live="off" id="loading-spinner"></div>
Implémentation JavaScript robuste
// Classe utilitaire pour les annonces accessibles
class AccessibleAnnouncer {
constructor() {
// Créer deux régions live (polite et assertive)
this.politeRegion = this.createRegion('polite');
this.assertiveRegion = this.createRegion('assertive');
}
createRegion(politeness) {
const region = document.createElement('div');
region.setAttribute('aria-live', politeness);
region.setAttribute('aria-atomic', 'true');
// Caché visuellement mais présent dans l'arbre d'accessibilité
Object.assign(region.style, {
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
});
document.body.appendChild(region);
return region;
}
announce(message, urgency = 'polite') {
const region = urgency === 'assertive' ? this.assertiveRegion : this.politeRegion;
// Vider d'abord pour que la même annonce re-déclenche le lecteur
region.textContent = '';
// Délai minimal pour forcer le re-déclenchement de l'annonce
requestAnimationFrame(() => {
region.textContent = message;
});
}
}
const announcer = new AccessibleAnnouncer();
// Usage dans un formulaire de recherche
searchInput.addEventListener('input', debounce(() => {
const count = performSearch(searchInput.value);
announcer.announce(`${count} résultats trouvés pour "${searchInput.value}"`);
}, 300));
Formulaires accessibles
Les formulaires sont la zone la plus critique en accessibilité. Un formulaire mal labellisé est inutilisable pour les utilisateurs de lecteurs d'écran, et pénalise aussi le SEO des pages de contact et d'inscription.
Structure de formulaire complète
<form novalidate aria-labelledby="form-title">
<h2 id="form-title">Créer un compte</h2>
<!-- Groupe email avec validation -->
<div class="form-group">
<label for="email">
Adresse email
<span aria-hidden="true" class="required-indicator"> *</span>
</label>
<input
id="email"
type="email"
name="email"
aria-required="true"
aria-describedby="email-hint email-error"
aria-invalid="false"
autocomplete="email"
/>
<span id="email-hint" class="form-hint">Format: nom@domaine.com</span>
<span id="email-error" role="alert" class="form-error" hidden>
Adresse email invalide
</span>
</div>
<!-- Groupe avec fieldset/legend pour les boutons radio -->
<fieldset>
<legend>Abonnement à la newsletter</legend>
<label>
<input type="radio" name="newsletter" value="yes">
Oui, je veux recevoir les actualités
</label>
<label>
<input type="radio" name="newsletter" value="no">
Non merci
</label>
</fieldset>
</form>
Gestion des erreurs de validation
// Validation accessible: aria-invalid + aria-describedby vers le message
function showFieldError(field, message) {
const errorId = field.id + '-error';
let errorEl = document.getElementById(errorId);
if (!errorEl) {
errorEl = document.createElement('span');
errorEl.id = errorId;
errorEl.setAttribute('role', 'alert'); // annonce immédiate
field.parentNode.appendChild(errorEl);
// Ajouter la référence dans aria-describedby
const existing = field.getAttribute('aria-describedby') || '';
field.setAttribute('aria-describedby', `${existing} ${errorId}`.trim());
}
field.setAttribute('aria-invalid', 'true');
errorEl.textContent = message;
errorEl.removeAttribute('hidden');
// Mettre le focus sur le premier champ en erreur
field.focus();
}
function clearFieldError(field) {
field.setAttribute('aria-invalid', 'false');
const errorEl = document.getElementById(field.id + '-error');
if (errorEl) errorEl.setAttribute('hidden', '');
}
ARIA dans les composants Angular
Angular facilite la gestion de l'accessibilité grâce au binding d'attributs ARIA, au module CDK (Component Dev Kit) qui fournit des primitives d'accessibilité réutilisables, et à des directives natives pour la gestion du focus.
Binding ARIA dans les templates Angular
// Composant accordéon Angular avec ARIA complet
@Component({
selector: 'app-accordion',
template: `
@for (item of items; track item.id) {
<div class="accordion-item">
<button
[attr.aria-expanded]="item.isOpen"
[attr.aria-controls]="'panel-' + item.id"
[id]="'trigger-' + item.id"
(click)="toggle(item)"
>
{{ item.title }}
</button>
<div
[id]="'panel-' + item.id"
role="region"
[attr.aria-labelledby]="'trigger-' + item.id"
[hidden]="!item.isOpen"
>
{{ item.content }}
</div>
</div>
}
`,
})
export class AccordionComponent {
items = signal([
{ id: '1', title: 'Question 1', content: 'Réponse 1', isOpen: false },
{ id: '2', title: 'Question 2', content: 'Réponse 2', isOpen: false },
]);
toggle(item: AccordionItem) {
item.isOpen = !item.isOpen;
}
}
Angular CDK: FocusTrap et LiveAnnouncer
// FocusTrap du CDK Angular pour les modales
import { FocusTrap, FocusTrapFactory } from '@angular/cdk/a11y';
@Component({
selector: 'app-modal',
template: `
<div
role="dialog"
[attr.aria-modal]="true"
[attr.aria-labelledby]="titleId"
cdkTrapFocus
cdkTrapFocusAutoCapture
>
<h2 [id]="titleId">{{ title }}</h2>
<ng-content></ng-content>
</div>
`,
})
export class ModalComponent {
readonly titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
@Input() title = '';
}
// LiveAnnouncer: annoncer des messages dynamiques
import { LiveAnnouncer } from '@angular/cdk/a11y';
@Component({ ... })
export class SearchComponent {
#announcer = inject(LiveAnnouncer);
async search(term: string) {
const results = await this.searchService.search(term);
// Annonce automatiquement aux lecteurs d'écran
await this.#announcer.announce(
`${results.length} résultats pour "${term}"`,
'polite'
);
}
}
Bonnes pratiques et erreurs à éviter
<button> est toujours préférable à <div role="button" tabindex="0">.
Les 5 règles d'utilisation d'ARIA
- Règle 1: Ne pas utiliser ARIA si un élément HTML natif ou un attribut HTML fait le travail.
- Règle 2: Ne pas changer la sémantique native, sauf en cas de nécessité absolue (ex: pas de
role="heading"sur un<button>). - Règle 3: Tous les widgets ARIA interactifs doivent être utilisables au clavier.
- Règle 4: Ne jamais utiliser
aria-hidden="true"sur un élément focusable. - Règle 5: Tous les éléments interactifs doivent avoir un nom accessible (aria-label, aria-labelledby, ou texte visible).
Erreurs les plus fréquentes en audit
| Erreur | Correction |
|---|---|
<img> sans alt |
alt="" si décorative, alt="description" si informative |
Bouton sans label (<button><svg/></button>) |
Ajouter aria-label et aria-hidden="true" sur le SVG |
| Contraste insuffisant (ratio < 4.5:1) | Utiliser WebAIM Contrast Checker et ajuster les couleurs |
| Focus non visible (outline supprimé) | Ne jamais supprimer outline sans remplaçant visible |
| Liens "Cliquez ici" sans contexte | Texte descriptif: "Télécharger le guide SEO 2026" |
| Langue de page non déclarée | <html lang="fr"> obligatoire |
Outils de test et automatisation
Les outils automatisés détectent environ 30-40% des problèmes d'accessibilité. Le reste nécessite des tests manuels et utilisateurs. Combinez les deux approches.
Intégration axe-core dans les tests automatisés
// axe-core dans Jest (tests unitaires Angular)
import { TestBed } from '@angular/core/testing';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('ButtonComponent', () => {
it('ne doit pas avoir de violations WCAG', async () => {
await TestBed.configureTestingModule({
imports: [ButtonComponent],
}).compileComponents();
const fixture = TestBed.createComponent(ButtonComponent);
fixture.detectChanges();
// Lance l'audit axe sur le composant rendu
const results = await axe(fixture.nativeElement);
expect(results).toHaveNoViolations();
});
});
// axe-core dans Cypress (tests E2E)
// cypress/support/commands.ts
import 'cypress-axe';
describe('Page d\'accueil', () => {
it('est accessible WCAG AA', () => {
cy.visit('/');
cy.injectAxe();
cy.checkA11y(null, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa'], // niveau AA uniquement
},
rules: {
'color-contrast': { enabled: true },
'keyboard-navigation': { enabled: true },
},
});
});
});
Outils de test par type
| Outil | Type | Ce qu'il détecte |
|---|---|---|
| axe DevTools | Extension navigateur | WCAG A/AA, bonnes pratiques |
| Lighthouse | Chrome DevTools / CI | Score accessibilité, contraste, labels |
| NVDA (Windows) | Lecteur d'écran | Annonces, ordre de lecture, ARIA |
| VoiceOver (macOS/iOS) | Lecteur d'écran natif | Safari + WebKit spécifique |
| Pa11y CI | CLI / GitHub Actions | Audit automatisé en déploiement continu |
| Colour Contrast Analyser | Desktop app | Ratio de contraste précis sur n'importe quelle couleur |
Checklist de validation avant livraison
- Navigation complète au clavier (Tab, Shift+Tab, Entrée, Espace, Échap)
- Tous les éléments interactifs ont un nom accessible visible au lecteur d'écran
- Skip link en premier élément du body
- Contrastes WCAG AA: texte normal 4.5:1, grand texte 3:1
- Modales avec piège de focus et restauration au fermeture
- Formulaires: labels, descriptions, messages d'erreur avec aria-invalid
- Images: alt descriptif ou alt="" si décoratif
- Langue déclarée sur
<html lang="fr"> - Score Lighthouse Accessibility ≥ 90
- Audit axe-core 0 violation critique