accessibilitéreactnext.js

Créer des composants accessibles aux lecteurs d'écran

Créer des composants accessibles aux lecteurs d'écran

Imaginez un utilisateur de NVDA qui ouvre une modale contenant un formulaire, le remplit, puis clique sur "Fermer". La modale disparaît visuellement — display: none en CSS, tout semble en ordre. Sauf que l'élément est toujours présent dans le DOM, le focus n'a bougé nulle part, et le lecteur d'écran continue de lire le contenu de la fenêtre qui vient d'être "fermée". L'utilisateur entend le contenu d'un formulaire qu'il vient de quitter et n'a aucune idée d'où il se trouve désormais sur la page. Ce n'est pas un exemple inventé — c'est l'une des erreurs les plus fréquentes dans des composants construits "à l'œil", sans comprendre comment fonctionne réellement l'accessibilité. Dans cet article, je veux aller au-delà d'une simple liste de règles et montrer le mécanisme qui les sous-tend — accompagné de quelques composants réels où vous verrez exactement où se cachent les pièges.

Comment un lecteur d'écran "voit" réellement une page

Avant de corriger des composants, il est utile de savoir avec quoi un lecteur d'écran travaille réellement. Il ne rend pas la page comme le fait un navigateur — il s'appuie sur l'arbre d'accessibilité (accessibility tree), une structure que le navigateur construit en parallèle du DOM. Chaque nœud de cet arbre possède trois propriétés fondamentales : un rôle (est-ce un bouton, un lien, un titre, un champ de formulaire), un nom (ce que l'utilisateur entend comme description) et un état (déplié, coché, désactivé).

La conséquence clé : un lecteur d'écran ne voit pas votre CSS. display: none et visibility: hidden retirent un élément de l'arbre d'accessibilité — c'est une manière correcte de le cacher. Mais déplacer simplement un élément hors de l'écran (position: absolute; left: -9999px) ou passer sa couleur à transparent ne change strictement rien à l'arbre d'accessibilité — l'élément est toujours là et continue d'être lu. C'est exactement ce qui a mal tourné dans l'exemple de la modale ci-dessus.

Le second pilier est l'ordre du DOM. En mode navigation, un lecteur d'écran lit la page dans l'ordre où les éléments apparaissent dans l'arbre — indépendamment de la façon dont vous les avez agencés visuellement avec flex-direction: row-reverse ou grid-template-areas. Si votre mise en page a un sens visuel mais que l'ordre du code source est arbitraire, un utilisateur non-voyant reçoit la page "dans le désordre".

La sémantique comme première ligne de défense

La façon la moins coûteuse d'être accessible correctement est d'utiliser le bon élément HTML plutôt que de réinventer son comportement de zéro. Regardez la différence :

jsx

// Mauvais – ressemble à un bouton, mais n'en est pas un
const SaveButton = ({ onSave }) => (
<div className="btn" onClick={onSave}>
Enregistrer les modifications
</div>
);

Ce div présente des lacunes sérieuses qui ne sautent pas aux yeux à première vue :

  • Il n'est pas focusable – on ne peut pas l'atteindre avec la touche Tab.
  • Il ne répond pas au clavier – Entrée et Espace ne font rien, car onClick de React n'écoute que les événements souris/tactile.
  • Il n'a pas de rôle button – un lecteur d'écran le lit comme du texte brut, sans aucune indication qu'il puisse être "activé".
  • Il n'a pas d'état désactivé – on ne peut pas simplement définir disabled, il faudrait le simuler manuellement.

jsx

// Bon – vous obtenez tout ce qui précède gratuitement
const SaveButton = ({ onSave, isSaving }) => (
<button type="button" onClick={onSave} disabled={isSaving}>
{isSaving ? "Enregistrement…" : "Enregistrer les modifications"}
</button>
);

Le même principe s'applique à <nav> plutôt qu'à <div className="nav">, à un <label> relié à un champ via htmlFor plutôt qu'un placeholder qui fait semblant d'être un label, ou à <ul>/<li> pour les listes plutôt qu'un empilement de <div>. Le HTML sémantique n'est pas "plus joli" — il génère littéralement une structure différente et plus riche dans l'arbre d'accessibilité.

ARIA : quand ça aide, et quand ça nuit

La spécification WAI-ARIA s'ouvre sur une règle qu'il vaut la peine de retenir mot pour mot : "No ARIA is better than Bad ARIA" — pas d'ARIA vaut mieux qu'un mauvais ARIA. Les attributs ARIA n'ajoutent aucun comportement — ils ne font que modifier ce qu'un lecteur d'écran rapporte à l'utilisateur. Si vous promettez quelque chose que le composant ne peut pas réellement tenir, vous êtes dans une situation pire que si vous n'aviez rien ajouté du tout.

jsx

// Mauvais – ARIA promet un bouton, mais rien d'autre ne soutient cette promesse
const DeleteIcon = ({ onDelete }) => (
<span role="button" aria-label="Supprimer l'élément" onClick={onDelete}>
🗑
</span>
);

Ce code indique au lecteur d'écran "ceci est un bouton" — mais n'ajoute ni support clavier ni tabIndex, si bien qu'un utilisateur au clavier ne peut jamais l'atteindre. C'est pire que l'absence totale de role, car cela donne l'impression que la fonctionnalité existe alors qu'elle est physiquement inaccessible.

jsx

// Bon – utilisez simplement un bouton natif
const DeleteIcon = ({ onDelete }) => (
<button type="button" onClick={onDelete} aria-label="Supprimer l'élément">
<span aria-hidden="true">🗑</span>
</button>
);

Remarquez le aria-hidden="true" sur l'emoji — sans lui, certains lecteurs d'écran tentent de lire à voix haute le nom du caractère Unicode ("corbeille"), ce qui sonne absurde juste après que aria-label a déjà été annoncé. aria-label remplace entièrement ce que l'utilisateur entend comme contenu visible — si un élément possède déjà un texte lisible, aria-labelledby pointant vers ce texte est généralement le meilleur choix, afin de ne pas maintenir deux descriptions indépendantes susceptibles de diverger au fil du temps.

Un exemple concret : un accordéon accessible

Un simple bouton ne montre pas grand-chose. Regardons un composant où l'on doit vraiment gérer l'état et les relations ARIA de manière délibérée — un accordéon, le motif des sections repliables courant dans les FAQ ou les panneaux de réglages.

jsx

import { useId, useState } from "react";
const AccordionItem = ({ title, children, defaultOpen = false }) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const contentId = useId();
return (
<div className="accordion-item">
<h3 className="accordion-header">
<button
type="button"
className="accordion-trigger"
aria-expanded={isOpen}
aria-controls={contentId}
onClick={() => setIsOpen((open) => !open)}
>
{title}
<span className="accordion-icon" aria-hidden="true">
{isOpen ? "−" : "+"}
</span>
</button>
</h3>
<div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
};
export default AccordionItem;

Plusieurs décisions dans ce code ne sont pas accidentelles :

  • aria-expanded signale l'état actuel – sans cela, un utilisateur de lecteur d'écran entend simplement "bouton", sans moyen de savoir si la section est ouverte.
  • aria-controls relie le bouton au panneau qu'il contrôle – certains lecteurs d'écran annoncent cette relation, ce qui facilite la navigation.
  • hidden (plutôt qu'un simple masquage via CSS) garantit que le contenu fermé est réellement retiré de l'arbre d'accessibilité et de l'ordre de tabulation, de sorte que le focus ne puisse pas atterrir dans un contenu invisible.
  • Le <h3> qui englobe le bouton préserve la hiérarchie des titres – les utilisateurs de lecteurs d'écran naviguent très souvent par titres, en sautant d'une section à l'autre avec la touche H.
  • L'icône +/ porte aria-hidden="true", car l'état est déjà communiqué via aria-expanded – sans cela, un lecteur d'écran l'annoncerait deux fois, de manière confuse.

Live regions et messages dynamiques

Autre problème courant : quelque chose change sur la page sans rechargement, et le lecteur d'écran ne s'en aperçoit jamais, parce qu'il n'a aucune raison de relire un fragment que l'utilisateur n'est pas en train d'explorer. Un exemple classique est une erreur de validation qui apparaît dynamiquement après avoir quitté un champ :

jsx

import { useState } from "react";
const EmailField = () => {
const [error, setError] = useState("");
const handleBlur = (event) => {
const value = event.target.value;
setError(value.includes("@") ? "" : "Saisissez une adresse e-mail valide.");
};
return (
<div className="field">
<label htmlFor="email">E-mail</label>
<input
id="email"
type="email"
aria-invalid={Boolean(error)}
aria-describedby={error ? "email-error" : undefined}
onBlur={handleBlur}
/>
<span id="email-error" role="alert" className="field-error">
{error}
</span>
</div>
);
};
export default EmailField;

role="alert" fait en sorte que l'élément se comporte comme une région implicite aria-live="assertive" – lorsque son contenu change, le lecteur d'écran interrompt ce qu'il est en train de lire et annonce immédiatement le nouveau message. C'est adapté aux erreurs qui exigent une attention urgente, mais évitez d'en abuser pour des mises à jour moins pressantes (comme "brouillon enregistré") – aria-live="polite" convient mieux dans ce cas, car il attend que l'utilisateur ait terminé son action en cours plutôt que de l'interrompre. aria-describedby associe en outre le message d'erreur au champ, de sorte que le lecteur d'écran le lise avec le label chaque fois que l'utilisateur revient sur ce champ — pas seulement au moment où l'erreur est apparue pour la première fois.

Gestion du focus lors de la navigation dans Next.js

Ce piège est spécifique aux applications monopages, y compris le App Router de Next.js. Lors d'une transition de page classique (rechargement complet), le navigateur réinitialise le focus sur <body>, et le lecteur d'écran annonce le nouveau titre du document — l'utilisateur sait qu'il vient d'atterrir sur une nouvelle page. Avec une navigation côté client, rien de tout cela ne se produit automatiquement : le focus reste sur le lien qui a été cliqué (souvent quelque part dans la navigation, en dehors du nouveau contenu), et le lecteur d'écran ne reçoit aucun signal indiquant qu'un changement a eu lieu.

jsx

"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
const RouteAnnouncer = ({ pageTitle }) => {
const pathname = usePathname();
const headingRef = useRef(null);
useEffect(() => {
headingRef.current?.focus();
}, [pathname]);
return (
<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">
{pageTitle}
</h1>
);
};
export default RouteAnnouncer;

L'astuce, c'est tabIndex={-1} – normalement, les titres ne sont pas focusables, mais cette valeur permet de leur donner le focus par programmation (.focus()) sans les ajouter à l'ordre de tabulation naturel. Après chaque changement de chemin (pathname), le focus revient sur le titre de la nouvelle page, si bien que le lecteur d'écran en annonce le titre — exactement comme lors d'un rechargement traditionnel. C'est un composant qu'il vaut la peine d'ajouter une fois à votre layout et de ne plus jamais y repenser.

Comment tester l'accessibilité pour de vrai

Les outils automatisés comme axe-core ou eslint-plugin-jsx-a11y valent la peine d'être activés dès le premier jour d'un projet – ils repèrent les erreurs évidentes (alt manquant, contraste insuffisant, labels absents) avant qu'elles n'atteignent la production. Mais soyez conscient de leurs limites : selon des recherches de Deque Systems, les outils automatisés détectent réalistement environ 30 à 40 % des problèmes d'accessibilité. Le reste nécessite une vérification manuelle, car il s'agit de sens et de contexte qu'une machine ne peut pas évaluer – l'ordre de lecture a-t-il vraiment du sens, un message d'erreur explique-t-il vraiment quoi faire, un piège à focus dans une modale empêche-t-il réellement l'utilisateur de s'échapper.

Un processus concret et reproductible qui détecte la plupart des problèmes réels :

  • Rangez la souris et parcourez tout le flux avec le clavier uniquement – Tab, Maj+Tab, Entrée, Espace, Échap, flèches directionnelles là où c'est naturel (par exemple dans un menu). Si à un moment donné vous ne savez pas où se trouve le focus, c'est déjà un bug.
  • Activez VoiceOver (macOS : Cmd+F5) ou NVDA (Windows, gratuit) et parcourez le même flux les yeux fermés. C'est le seul moyen de révéler si l'ordre, les noms et les états ont vraiment du sens à l'oral, pas seulement sur le papier.
  • Testez les messages dynamiques séparément – déclenchez une erreur de formulaire, changez de route, ouvrez une modale — et vérifiez si le lecteur d'écran a réellement annoncé quelque chose, pas seulement si l'élément possède le bon attribut dans le code.

Conclusion

Les composants accessibles ne sont pas une liste d'attributs ajoutés à la fin — ils sont la conséquence de la façon dont vous modélisez l'état et la structure dès le départ. Trois points à retenir de cet article : premièrement, le HTML sémantique vous offre gratuitement la focusabilité, le support clavier et un rôle correct — ARIA doit combler ce que le HTML ne peut pas exprimer, pas le remplacer dans des composants qui pourraient tout aussi bien être des éléments natifs. Deuxièmement, les changements d'état et de contenu doivent être activement annoncés — aria-live, role="alert" et la gestion du focus après la navigation ne sont pas des extras, ils sont la condition pour qu'une application dynamique soit utilisable sans la vue. Troisièmement, aucun outil automatisé ne remplace le fait de parcourir sa propre interface les yeux fermés — c'est le moyen le plus rapide de voir exactement où votre composant "accessible" perd réellement l'utilisateur.