Skip to Content

Chapitre 4.4 — Gérer le focus en JS

⏱️ TL;DR — Dès qu’une action change le contenu à l’écran, tu dois souvent déplacer le focus par programme pour que l’utilisateur clavier suive. Les cas canoniques : ouvrir une modale → focus dans la modale (et le piéger dedans) ; la fermer → focus rendu au déclencheur ; supprimer un élément d’une liste → focus sur le suivant ; changer de route (SPA) → focus/annonce sur le nouveau titre. Et le grand classique : les liens d’évitement (skip links, WCAG 2.4.1) pour sauter la navigation répétitive.

🎯 Objectifs

  • Savoir quand et comment déplacer le focus en JavaScript.
  • Gérer le cycle d’une modale : focus à l’ouverture, piège, retour au déclencheur.
  • Gérer le focus après une suppression et lors d’un changement de route.
  • Implémenter un lien d’évitement conforme au critère 2.4.1.

La règle : le focus doit suivre l’action

Au clavier, le focus est la position de l’utilisateur. Quand un clic (ou un Entrée) fait apparaître, disparaître ou remplacer du contenu, le focus ne doit pas rester « coincé » sur un élément devenu hors sujet — ou pire, disparaître dans le vide. À toi de le déplacer vers l’endroit pertinent. C’est le cœur de l’accessibilité des interfaces dynamiques.

L’outil de base est element.focus(), souvent sur une cible portant tabindex="-1" (voir Chapitre 4.3) quand la cible n’est pas nativement focusable.

Ouvrir et fermer une modale

C’est le scénario le plus emblématique. Quatre gestes obligatoires :

  1. À l’ouverture, mémoriser l’élément déclencheur, puis déplacer le focus dans la modale (le conteneur, ou le premier élément interactif).
  2. Pendant, piéger le focus dans la modale (le focus trap, voir plus bas).
  3. Échap doit fermer la modale.
  4. À la fermeture, rendre le focus au déclencheur mémorisé.
// ❌ On ouvre la modale mais on ne touche pas au focus function ouvrir() { modale.hidden = false; // Le focus reste sur le bouton, derrière la modale : l'utilisateur clavier est « dehors » }
// ✅ Cycle complet : mémoriser, déplacer, restaurer let declencheur = null; function ouvrir() { declencheur = document.activeElement; // 1. mémoriser qui a ouvert modale.hidden = false; modale.focus(); // 2. déplacer le focus dans la modale (tabindex="-1") } function fermer() { modale.hidden = true; declencheur?.focus(); // 4. rendre le focus au déclencheur } // 3. Échap ferme modale.addEventListener("keydown", (e) => { if (e.key === "Escape") fermer(); });

Sans l’étape 4, l’utilisateur clavier se retrouve, après fermeture, projeté au début de la page : il a perdu sa place. Rendre le focus au déclencheur, c’est lui rendre son fil.

Le focus trap (principe)

Tant que la modale est ouverte, le focus ne doit pas pouvoir en sortir au Tab : sinon l’utilisateur tabule « derrière » la modale, dans la page inerte, sans savoir où il est. C’est le focus trap. Le principe :

  • On repère le premier et le dernier élément focusable de la modale.
  • Sur Tab depuis le dernier → on renvoie le focus sur le premier.
  • Sur Shift+Tab depuis le premier → on renvoie sur le dernier.
// Principe du piège : on « boucle » le Tab à l'intérieur de la modale modale.addEventListener("keydown", (e) => { if (e.key !== "Tab") return; const focusables = modale.querySelectorAll( 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const premier = focusables[0]; const dernier = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === premier) { dernier.focus(); e.preventDefault(); } else if (!e.shiftKey && document.activeElement === dernier) { premier.focus(); e.preventDefault(); } });

Une alternative moderne, souvent plus propre, consiste à neutraliser tout le reste de la page avec l’attribut inert pendant que la modale est ouverte (voir Chapitre 4.5). En React/Next.js, tu utiliseras plutôt une primitive éprouvée (voir Partie 11) plutôt que de réécrire un piège à la main. Le pattern complet « Dialog (Modal) » est traité en Partie 10.

⚠️ Piège — Un focus trap sans sortie (Échap qui ne ferme pas, ou aucun moyen de quitter) devient un piège au clavier, interdit par le critère 2.1.2 (voir Chapitre 4.5). Un bon piège retient le focus dans la modale tant qu’elle est ouverte, mais laisse toujours une porte de sortie : Échap, ou un bouton « Fermer ».

Après une suppression d’élément

Tu supprimes une ligne d’une liste (un item du panier, une notification). Si cet item avait le focus, où va le focus après sa disparition ? Par défaut : nulle part — il retombe sur <body>, et l’utilisateur clavier est éjecté au début de la page.

Le bon comportement : déplacer le focus vers l’élément suivant de la liste (ou le précédent s’il n’y a plus de suivant, ou un message « liste vide »).

// ✅ Avant de retirer l'item, on prévoit où ira le focus function supprimer(item) { const cible = item.nextElementSibling ?? item.previousElementSibling; item.remove(); cible?.querySelector("button, a")?.focus(); // focus sur l'item voisin restant }

Au changement de route (SPA / Next.js)

Dans une application à navigation côté client (React Router, App Router de Next.js), cliquer sur un lien ne recharge pas la page : le navigateur ne remet donc pas le focus en haut, et le lecteur d’écran n’annonce pas de nouvelle page. L’utilisateur reste « bloqué » sur l’ancien contexte.

Le remède : après le changement de route, déplacer le focus sur le titre principal (<h1>) de la nouvelle vue — ou sur un conteneur — pour que la nouvelle page soit annoncée et que la tabulation reparte du bon endroit.

// ✅ Après navigation client, on envoie le focus sur le titre de la nouvelle vue function apresNavigation() { const titre = document.querySelector("main h1"); titre?.setAttribute("tabindex", "-1"); // le rendre ciblable titre?.focus(); // le lecteur d'écran annonce la nouvelle page }

Les détails spécifiques à React et à l’App Router (où placer ce code, gestion des annonces) sont en Partie 11.

Le critère WCAG 2.4.1 (Contourner des blocs), niveau A, demande un moyen de sauter les blocs de contenu répétés d’une page à l’autre — au premier chef, le menu de navigation. Sans ça, l’utilisateur clavier doit tabuler à travers des dizaines de liens de menu sur chaque page avant d’atteindre le contenu. Épuisant.

La solution standard : un lien d’évitement (skip link), premier élément focusable de la page, caché visuellement mais révélé au focus, qui saute vers le contenu principal.

<!-- ✅ Tout premier élément du <body> : la première tabulation tombe dessus --> <a class="skip-link" href="#contenu">Aller au contenu principal</a> <header> … navigation répétitive … </header> <main id="contenu" tabindex="-1"> <h1>Titre de la page</h1> </main>
/* ✅ Caché hors focus, mais BIEN visible dès qu'il reçoit le focus au clavier */ .skip-link { position: absolute; left: -9999px; /* hors écran par défaut */ top: 0; } .skip-link:focus { left: 1rem; /* réapparaît en haut à gauche au focus clavier */ z-index: 1000; padding: 0.5rem 1rem; background: #1a5cff; color: #fff; }

Le tabindex="-1" sur <main id="contenu"> garantit que, une fois le lien activé, le focus se pose réellement sur le contenu (et pas seulement le scroll) : la tabulation suivante repart de là.

🧭 Sur A11yLearn — Sur A11yLearn, ouvrir le panneau latéral d’un cours (le sommaire des leçons) déclenchait l’apparition du panneau… sans toucher au focus : l’utilisateur clavier continuait à tabuler dans la page derrière, sans jamais entrer dans le sommaire. On a appliqué le cycle complet : à l’ouverture, focus sur le titre du panneau (tabindex="-1") + focus piégé dedans ; Échap referme ; à la fermeture, le focus revient sur le bouton « Sommaire » qui l’avait ouvert. On a aussi ajouté un lien d’évitement « Aller au contenu du cours » en tête de chaque page de leçon — l’en-tête de navigation d’A11yLearn compte une quinzaine de liens.

✏️ Exercices

Exercice 1 — Répare la fermeture de modale. Ce code ouvre bien la modale et y place le focus, mais après fermeture l’utilisateur clavier se retrouve en haut de la page. Corrige.

function ouvrir() { modale.hidden = false; modale.focus(); } function fermer() { modale.hidden = true; }

✅ Solution

Il manque la mémorisation du déclencheur et la restauration du focus à la fermeture :

let declencheur = null; function ouvrir() { declencheur = document.activeElement; // mémoriser modale.hidden = false; modale.focus(); } function fermer() { modale.hidden = true; declencheur?.focus(); // rendre le focus au déclencheur }

Sans ça, à la fermeture le focus retombe sur <body> et l’utilisateur perd sa place. On lui rend son point de départ.

Exercice 2 — Skip link. On te dit : « Le skip link, on l’a mis mais on l’a caché avec display: none pour ne pas l’afficher. » Quel est le problème, et quelle est la bonne technique ?

✅ Solution

display: none retire l’élément de l’ordre de tabulation : le lien d’évitement devient non focusable, donc totalement inopérant au clavier — l’inverse du but recherché (voir Chapitre 4.5). La bonne technique : le cacher visuellement tout en le gardant focusable (position: absolute; left: -9999px) et le révéler au focus (:focus). Il reste invisible pour l’utilisateur souris, mais apparaît dès que l’utilisateur clavier l’atteint.

🧠 Quiz de révision

1. À l’ouverture d’une modale, que faut-il faire du focus ?

Le déplacer dans la modale (sur le conteneur en tabindex="-1" ou le premier élément interactif), après avoir mémorisé le déclencheur. Il faut aussi piéger le focus dans la modale et permettre Échap.

2. Où doit repartir le focus quand on ferme une modale ?

Sur l’élément déclencheur qui l’avait ouverte (mémorisé à l’ouverture avec document.activeElement). Sinon le focus retombe sur <body> et l’utilisateur clavier est renvoyé au début de la page.

3. Qu’est-ce qu’un focus trap, et quelle limite ne pas franchir ?

C’est le fait de retenir le focus à l’intérieur d’une modale ouverte (le Tab boucle du dernier au premier élément). La limite : il doit toujours exister une sortie (Échap ou bouton « Fermer »), sinon c’est un piège au clavier interdit par le critère 2.1.2.

4. Pourquoi gérer le focus au changement de route dans une SPA ?

Parce que la navigation côté client ne recharge pas la page : le navigateur ne remet pas le focus en haut et le lecteur d’écran n’annonce pas la nouvelle vue. On déplace donc le focus sur le <h1> de la nouvelle page (rendu ciblable avec tabindex="-1") pour qu’elle soit annoncée.

5. À quoi sert un lien d’évitement, et quel critère satisfait-il ?

À sauter les blocs répétitifs (surtout le menu de navigation) pour aller directement au contenu, évitant de tabuler à travers des dizaines de liens sur chaque page. Il satisfait le critère 2.4.1 (Contourner des blocs). On le cache visuellement mais on le garde focusable et on le révèle au focus.


Chapitre suivant : Pièges & contenu masqué — les pièges au clavier, et la différence cruciale entre display:none, aria-hidden et inert.

Last updated on