Chapitre 11.3 — Gérer le focus en React
⏱️ TL;DR — En React, tu ne touches pas le DOM à la main : le focus se pilote avec une ref (
useRef) et.focus(). Trois moments comptent : au montage (focus sur un titre après navigation, sur le premier champ d’une modale) — viauseEffect; pendant l’ouverture d’un composant modal — il faut piéger le focus (focus trap) pour qu’il n’en sorte pas ; à la fermeture — il faut restituer le focus à l’élément déclencheur. Et un piège :autoFocusest pratique mais brutal, à manier avec prudence.
🎯 Objectifs
- Déplacer le focus proprement avec
useRef+.focus(). - Focaliser un élément au montage avec
useEffect, en gérant le timing. - Comprendre le focus trap et écrire un hook réutilisable.
- Restituer le focus à l’élément déclencheur à la fermeture.
- Savoir quand
autoFocusaide et quand il nuit.
Le focus se pilote par ref
Rappel du Chapitre 11.1 : en React, on n’écrit pas document.querySelector(...).focus(). On attache une ref à l’élément et on appelle .focus() dessus. C’est la base de tout ce chapitre.
import { useRef } from "react";
function ChampNom() {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<button onClick={() => ref.current?.focus()}>Aller au nom</button>
<input ref={ref} aria-label="Nom" />
</>
);
}Le ?. (optional chaining) est important : ref.current vaut null tant que l’élément n’est pas monté. On y revient — c’est une histoire de timing.
Focus au montage : useEffect et le bon timing
Le cas le plus fréquent : donner le focus à un élément dès qu’il apparaît — le <h1> d’une nouvelle page (Chapitre 11.2), le premier champ d’une modale, un message d’erreur. Le piège du débutant est d’appeler .focus() pendant le rendu : à ce moment, l’élément n’existe pas encore dans le DOM, ref.current est null, rien ne se passe.
La règle : le focus est un effet de bord. On l’exécute dans useEffect, qui se déclenche après que React a monté le DOM.
import { useRef, useEffect } from "react";
function TitrePage({ titre }: { titre: string }) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// le DOM est monté : la ref pointe vers un vrai élément
ref.current?.focus();
}, []); // [] = une seule fois, au montage
return (
<h1 tabIndex={-1} ref={ref}>
{titre}
</h1>
);
}Le tabIndex={-1} rend le <h1> focusable par programme sans l’ajouter à l’ordre de tabulation (voir Chapitre 4.3). Sans lui, un .focus() sur un titre n’a aucun effet : par défaut, un titre n’est pas focusable.
💡 Réflexe — Un
.focus()qui « ne marche pas » ? Deux causes dans 90 % des cas : (1) tu l’appelles trop tôt (horsuseEffect, avant le montage) ; (2) la cible n’est pas focusable (il manquetabIndex={-1}sur un élément non interactif comme un<h1>ou une<div>). Vérifie ces deux points avant de chercher plus loin.
Piéger le focus dans une modale (focus trap)
Quand une modale (dialogue) s’ouvre, le focus doit y rester enfermé tant qu’elle est ouverte : Tab sur le dernier élément revient au premier, Shift+Tab sur le premier va au dernier. Sinon, l’utilisateur clavier « sort » de la modale et se retrouve à tabuler dans la page derrière — qu’il ne voit pas. C’est le focus trap, exigé par le pattern Dialog de l’APG (Partie 10).
Voici un hook maison minimal. En production, préfère une librairie éprouvée (Chapitre 11.4) — mais le comprendre aide à savoir ce qu’elle fait.
import { useEffect, RefObject } from "react";
const SELECTEURS =
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
export function useFocusTrap(ref: RefObject<HTMLElement>, actif: boolean) {
useEffect(() => {
if (!actif || !ref.current) return;
const conteneur = ref.current;
const focusables = conteneur.querySelectorAll<HTMLElement>(SELECTEURS);
const premier = focusables[0];
const dernier = focusables[focusables.length - 1];
premier?.focus(); // focus initial dans la modale
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === premier) {
e.preventDefault();
dernier?.focus();
} else if (!e.shiftKey && document.activeElement === dernier) {
e.preventDefault();
premier?.focus();
}
}
conteneur.addEventListener("keydown", onKeyDown);
return () => conteneur.removeEventListener("keydown", onKeyDown);
}, [ref, actif]);
}Restituer le focus à la fermeture
Un focus trap bien fait ne suffit pas : à la fermeture, il faut rendre le focus à l’élément qui a ouvert la modale (le bouton « Ouvrir »). Sinon le focus retombe en haut du document ou disparaît, et l’utilisateur clavier est perdu. Le pattern : mémoriser l’élément actif avant d’ouvrir, le re-focaliser après avoir fermé.
import { useRef, useEffect } from "react";
function useRestituerFocus(ouvert: boolean) {
const declencheur = useRef<HTMLElement | null>(null);
useEffect(() => {
if (ouvert) {
// on mémorise qui avait le focus avant l'ouverture
declencheur.current = document.activeElement as HTMLElement;
} else {
// à la fermeture, on le lui rend
declencheur.current?.focus();
}
}, [ouvert]);
}Résumé du cycle de vie du focus d’une modale : mémoriser → focaliser le premier élément → piéger → restituer. Les quatre étapes sont indissociables.
autoFocus : prudence
L’attribut autoFocus place le focus sur un élément dès son montage. C’est tentant et parfois justifié (premier champ d’une modale déjà ouverte au chargement). Mais il est brutal :
- Sur une page qui se charge, un
autoFocusdéplace le focus sans prévenir et fait sauter l’utilisateur au milieu du contenu, avant même qu’il ait entendu le titre. - Il est implicite : difficile à suivre quand plusieurs composants en abusent.
eslint-plugin-jsx-a11yle signale par défaut, précisément pour ces raisons.
Préfère un focus explicite et contrôlé (useEffect + ref), déclenché au bon moment (ouverture d’un dialogue, pas chargement d’une page). Tu gardes ainsi la maîtrise du quand.
⚠️ Piège —
autoFocussur un champ en haut d’une page (une barre de recherche, par exemple) : à chaque arrivée sur la page, l’utilisateur de lecteur d’écran est catapulté dans le champ et rate le titre, le fil d’Ariane, le contexte. Ce qui semble « pratique » désoriente. RéserveautoFocus(ou un focus programmatique) aux dialogues que l’utilisateur vient explicitement d’ouvrir.
🧭 Sur A11yLearn — Sur A11yLearn, ouvrir une leçon depuis « Mes cours » chargeait le contenu mais laissait le focus sur la carte cliquée. On a appliqué le pattern de ce chapitre : au montage de la page de leçon, un
useEffectplace le focus sur le<h1>(titre du cours), entabIndex={-1}. Le lecteur d’écran annonce alors « Introduction à l’accessibilité, titre niveau 1 », et leTabsuivant part du contenu de la leçon. Pour la modale « Confirmer l’inscription », on a ajoutéuseFocusTrapet la restitution du focus vers le bouton « S’inscrire » à la fermeture — le cycle complet.
✏️ Exercices
Exercice 1 — Corrige le timing. Ce composant veut focaliser son champ au montage, mais « ça ne marche pas ». Trouve les deux erreurs.
function Modale() {
const ref = useRef<HTMLInputElement>(null);
ref.current.focus(); // focus au rendu
return <input ref={ref} aria-label="Recherche" />;
}✅ Solution
Erreur 1 : .focus() est appelé pendant le rendu, avant que l’élément soit monté — ref.current vaut null, ce qui plante (Cannot read properties of null). Il faut le mettre dans un useEffect(() => { ref.current?.focus(); }, []). Erreur 2 (robustesse) : utiliser l’optional chaining ref.current?.focus() pour ne jamais lire .focus() sur null.
function Modale() {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
return <input ref={ref} aria-label="Recherche" />;
}Exercice 2 — Le cycle complet. Liste, dans l’ordre, les quatre choses à gérer pour le focus d’une modale accessible.
✅ Solution
(1) Mémoriser l’élément qui avait le focus avant l’ouverture (le bouton déclencheur). (2) Focaliser un élément à l’intérieur de la modale à l’ouverture (premier champ ou titre). (3) Piéger le focus (focus trap) : Tab/Shift+Tab boucle dans la modale. (4) Restituer le focus au déclencheur à la fermeture. Oublier l’un des quatre casse l’expérience clavier.
🧠 Quiz de révision
1. Pourquoi appelle-t-on .focus() dans useEffect et pas dans le rendu ?
.focus() dans useEffect et pas dans le rendu ?Parce que le focus est un effet de bord sur le DOM, et que useEffect s’exécute après le montage. Pendant le rendu, l’élément n’existe pas encore : ref.current est null. Dans useEffect(() => {…}, []), la ref pointe vers un vrai élément.
2. Pourquoi mettre tabIndex={-1} sur un <h1> qu’on veut focaliser ?
tabIndex={-1} sur un <h1> qu’on veut focaliser ?Parce qu’un titre n’est pas focusable par défaut : .focus() n’aurait aucun effet. tabIndex={-1} le rend focusable par programme sans l’ajouter à l’ordre de tabulation (Tab). C’est la cible idéale pour poser le focus après une navigation.
3. Qu’est-ce qu’un focus trap et pourquoi une modale en a besoin ?
C’est le fait d’enfermer le focus dans la modale tant qu’elle est ouverte : Tab boucle du dernier au premier élément, et inversement. Sans lui, l’utilisateur clavier sort de la modale et tabule dans la page derrière (masquée), sans s’en rendre compte. C’est exigé par le pattern Dialog de l’APG.
4. Que veut dire « restituer le focus » et quand ?
À la fermeture d’une modale, rendre le focus à l’élément qui l’avait avant l’ouverture (le bouton déclencheur). On le mémorise à l’ouverture (document.activeElement) et on le re-focalise à la fermeture. Sinon le focus se perd et l’utilisateur clavier est désorienté.
5. Pourquoi se méfier de autoFocus ?
autoFocus ?Parce qu’il déplace le focus automatiquement au montage, souvent au chargement d’une page : l’utilisateur est catapulté dans un champ et rate le titre et le contexte. Il est implicite et difficile à suivre. eslint-plugin-jsx-a11y le signale. Préfère un focus explicite (useEffect + ref) déclenché sur une action volontaire.
Chapitre suivant : Les librairies accessibles — Radix, React Aria, Headless UI, Ariakit : ne réinvente pas la modale.