Technique20 février 2025Mis à jour le 5 août 20268 min

Accessibilité des SPA React et Vue : focus, titre, clavier

En Bref : L'essentiel à retenir

  • Une navigation SPA est un changement de contexte au sens du RGAA : le glossaire vise « tout ce qui, pour l'utilisateur, aurait l'air d'un déplacement vers une autre page ».
  • Le navigateur ne fait plus trois choses à votre place : annoncer le nouveau titre, replacer le focus, réinitialiser l'ordre de tabulation.
  • Mettre à jour document.title ne suffit pas : sans déplacement du focus, le lecteur d'écran reste sur le lien cliqué et n'annonce rien.
  • Quatre critères sont en jeu selon le cas : 8.5 et 8.6 pour le titre, 7.4 pour le changement de contexte, 7.5 pour les mises à jour sans changement de page, 12.8 pour la tabulation.
ReactVueSPAFocusRGAA

Une Single Page Application ne recharge pas la page, et c'est précisément le problème. Le rechargement n'était pas qu'un coût réseau : c'était le moment où le navigateur annonçait le nouveau titre, replaçait le focus en haut du document et réinitialisait l'ordre de tabulation. Trois services rendus gratuitement, que React Router et Vue Router ne rendent plus.

Le résultat est une application parfaitement utilisable à l'œil et silencieuse à l'oreille. L'utilisateur clique sur un lien de navigation, la vue change, et son lecteur d'écran ne dit rien : le focus est resté sur le lien qu'il vient d'activer.

Cet article couvre ce qu'il faut recoder soi-même, et quels critères du RGAA sont réellement en jeu dans chaque cas.

Ce que le RGAA dit d'une navigation SPA

Le point de départ n'est pas une question d'outil mais de définition. Le glossaire du RGAA décrit le changement de contexte comme incluant « le déplacement vers une nouvelle page, y compris tout ce qui, pour l'utilisateur, aurait l'air d'un déplacement vers une autre page ».

La formule est explicite : peu importe qu'il y ait ou non une requête HTTP. Si l'utilisateur a l'impression de changer de page, c'est un changement de contexte, avec les obligations qui vont avec.

Une nuance compte pour appliquer correctement le critère 7.4, qui demande si l'utilisateur « est averti ou en a le contrôle ». Il vise les scripts qui initient le changement. Quand l'utilisateur clique lui-même sur un lien de navigation, il en a le contrôle par construction : le critère est satisfait. Ce sont les changements de route déclenchés sans action de sa part qui posent problème, typiquement une redirection après connexion, un renvoi automatique au bout de quelques secondes, ou une navigation provoquée par un événement en arrière-plan.

Le titre de page : critères 8.5 et 8.6

Le critère 8.5 demande que chaque page ait un titre, le 8.6 que ce titre soit pertinent. Sur une SPA mal configurée, document.title reste figé sur celui de l'index pendant toute la session. Toutes les vues portent alors le même titre, ce qui échoue au 8.6 même si le 8.5 est formellement respecté.

La correction est triviale et doit être systématique :

CODE
document.title = `${titreDeLaVue} - Nom de l'application`;

Attention à un piège de mesure : la plupart des outils automatiques n'auditent que le HTML initial servi par le serveur. Ils voient le titre de l'index et concluent que tout va bien, sur les vingt vues de l'application. C'est un cas où un scan ne remplace pas un parcours réel.

Attention

Mettre à jour le titre ne fait annoncer rien du tout. Aucun lecteur d'écran ne signale spontanément un changement de document.title. Le titre sera correct si l'utilisateur le demande, mais il n'apprendra pas que la vue a changé. C'est l'objet de la section suivante, et les deux gestes sont complémentaires.

Déplacer le focus, le geste que personne ne fait

C'est ce qui manque dans la quasi-totalité des applications auditées. Après un changement de route, le focus doit être déplacé vers le début de la nouvelle vue.

La cible reçoit tabindex="-1", qui la rend focalisable par script sans l'insérer dans l'ordre de tabulation :

CODE
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

export function useFocusOnRouteChange() {
  const location = useLocation();
  const premierRendu = useRef(true);

  useEffect(() => {
    // Au chargement initial, le navigateur fait déjà le travail :
    // voler le focus ici perturberait la lecture de la page d'entrée.
    if (premierRendu.current) {
      premierRendu.current = false;
      return;
    }

    const cible = document.querySelector('h1');
    cible?.focus();
  }, [location.pathname]);
}

Trois détails font la différence entre un correctif et une régression :

  • Ne rien faire au premier rendu. Déplacer le focus au chargement initial coupe l'annonce naturelle de la page et fait perdre le contexte d'entrée.
  • Neutraliser le contour sur cette cible précise. Un h1 qui prend le focus affiche un anneau de focus que l'utilisateur voyant ne comprend pas, puisqu'il n'a rien tabulé. On le retire sur ce sélecteur uniquement, jamais globalement : le style de focus reste obligatoire partout ailleurs, comme le rappelle notre guide sur les indicateurs de focus.
  • Ne pas déplacer le focus sur les navigations mineures. Un changement de paramètre de tri dans l'URL n'est pas un changement de vue. Le déplacer à chaque mise à jour de query string rend l'application insupportable.

Ordre de tabulation : critère 12.8

Le critère 12.8 exige un ordre de tabulation cohérent. Sur une application classique il est acquis par la structure du document ; sur une SPA il se casse sans prévenir, parce que l'ordre suit le DOM et que le DOM ne suit plus la mise en page.

Les cas les plus fréquents :

  • Panneaux et tiroirs montés en fin de document. Un panneau latéral affiché à gauche mais inséré en dernier dans le DOM se tabule en dernier. Visuellement au début, au clavier à la fin.
  • Contenu masqué encore focalisable. Un menu fermé par opacity: 0 ou transform reste dans l'ordre de tabulation : l'utilisateur tabule dans le vide. Le masquage doit passer par display: none, hidden, ou inert sur le conteneur.
  • Rendu conditionnel qui déplace le focus au néant. Quand le composant qui portait le focus est démonté, le focus retombe sur <body> et la tabulation repart du début du document.

Le critère 7.1 complète le tableau : tout ce qu'un script rend interactif doit être utilisable au clavier. Un élément rendu cliquable par un gestionnaire onClick sur une div échoue, quel que soit le framework.

Mises à jour sans changement de vue : critère 7.5

Toutes les mises à jour ne sont pas des navigations. Filtrer une liste, valider un champ, sauvegarder automatiquement : le contenu change, la vue reste la même, et le focus ne doit surtout pas bouger.

Le glossaire du RGAA appelle cela un message de statut, et le définit précisément comme informant « d'un changement de contenu dans la page sans interrompre son activité principale ». C'est le domaine du critère 7.5, et il se traite avec une région live, pas avec un déplacement de focus.

CODE
<div role="status" aria-atomic="true" className="sr-only">
  {resultats.length} résultats correspondent à vos critères.
</div>

La distinction est la clé de voûte de tout ce qui précède : si l'utilisateur a l'impression de changer de page, on déplace le focus ; si le contenu se met à jour autour de lui, on annonce sans le déranger. Le détail du mécanisme est traité dans notre article sur aria-live et les messages de statut, et les valeurs polite et assertive dans le guide aria-live.

Le cas des composants réutilisables

Un composant de carte ou de section rendu à plusieurs endroits de l'application impose souvent un niveau de titre fixe, h2 par exemple, quel que soit son emplacement. Selon la vue, la hiérarchie saute un niveau ou en répète un.

C'est un problème propre aux architectures à composants, et il se traite au niveau de la structure de titres de la page, pas du composant pris isolément.

Comment vérifier

Aucun scan de la page d'entrée ne révélera ces défauts, puisqu'ils n'apparaissent qu'après interaction. La vérification est un parcours, pas une analyse.

  1. Naviguez au clavier uniquement. Depuis la page d'accueil, atteignez trois vues différentes sans toucher la souris. Après chaque changement, appuyez une fois sur Tab : si vous repartez du tout début du document, le focus n'a pas été replacé.
  2. Vérifiez le titre à chaque vue. L'onglet du navigateur doit changer, et refléter la vue, pas l'application.
  3. Écoutez. Avec NVDA ou VoiceOver, faites le même parcours sans regarder l'écran. Un silence complet après un clic de navigation est le symptôme central.
  4. Filtrez une liste. Le nombre de résultats doit être annoncé sans que le focus quitte le champ de filtre.

Les outils qui aident sur ce terrain, et leurs limites, sont recensés dans tester l'accessibilité JavaScript.

Ce qu'il faut retenir

Une SPA n'est pas moins accessible par nature : elle vous confie simplement des responsabilités que le navigateur assumait seul. Trois d'entre elles suffisent à couvrir l'essentiel, et elles tiennent dans une définition de fini : à chaque changement de vue, le titre change, le focus est replacé, et l'ordre de tabulation reste celui de la mise en page.

Vous développez une application React ou Vue ? Lancez un audit de votre page pour dégrossir le statique, puis faites le parcours clavier ci-dessus : c'est lui qui trouvera ce qu'aucun scan ne voit.

Questions fréquentes

Une navigation dans une SPA est-elle un changement de contexte au sens du RGAA ?

Oui. Le glossaire du RGAA définit le changement de contexte comme incluant « le déplacement vers une nouvelle page (y compris tout ce qui, pour l'utilisateur, aurait l'air d'un déplacement vers une autre page) ». Une route React Router ou Vue Router entre exactement dans cette définition, même sans rechargement HTTP. En revanche, le critère 7.4 porte sur les scripts qui *initient* ce changement : quand l'utilisateur clique lui-même sur un lien, il en a le contrôle. Le critère se joue sur les changements de route déclenchés sans action de sa part.

Faut-il déplacer le focus sur le h1 ou sur le conteneur principal ?

Les deux fonctionnent, avec une nuance. Déplacer le focus sur le h1 fait annoncer le titre de la nouvelle vue, ce qui confirme l'arrivée. Le placer sur le conteneur principal fait annoncer davantage de contenu et convient quand la vue n'a pas de titre unique. Dans les deux cas, la cible doit porter tabindex="-1" pour être focalisable par script sans entrer dans l'ordre de tabulation, et le style de focus doit être neutralisé sur cet élément précis, sinon un contour apparaît sans raison visible pour l'utilisateur voyant.

Mettre à jour document.title suffit-il ?

Non, et c'est l'erreur la plus répandue. Mettre à jour le titre satisfait les critères 8.5 et 8.6, et le titre sera lu si l'utilisateur le demande, mais aucun lecteur d'écran n'annonce spontanément un changement de document.title. Sans déplacement du focus, l'utilisateur reste positionné sur le lien qu'il vient d'activer et n'a aucun signal que la vue a changé. Les deux gestes sont complémentaires, pas alternatifs.

Quels critères RGAA une SPA risque-t-elle de casser sans le savoir ?

Principalement 8.5 et 8.6 (titre de page présent et pertinent, souvent figé sur le titre de l'index), 12.8 (ordre de tabulation cohérent, cassé quand un panneau s'ouvre en fin de DOM), 7.5 (messages de statut, quand un filtre met à jour une liste sans rien annoncer) et 7.4 (changement de contexte non contrôlé, sur les redirections automatiques). S'y ajoute 9.1 quand un composant réutilisable impose un niveau de titre fixe quel que soit son emplacement.

Vidéos RGAA sur ce thème

Pour voir ces sujets en action, regardez les épisodes vidéo correspondants :

Votre site est-il conforme ?

Ne prenez pas de risques avec l'accessibilité. Lancez un audit complet de votre site en quelques minutes et obtenez un rapport détaillé des corrections à apporter.