document.activeElement répond presque toujours à la question « où est le focus clavier en ce moment ? », sauf au moment précis où il faudrait s’en souvenir. Dès qu’un panneau latéral, un menu contextuel ou un tiroir de filtres se ferme, l’élément qui l’avait ouvert doit récupérer le focus. Sinon, le curseur clavier retombe sur le <body> et l’utilisateur doit tout retrouver à l’aveugle.
Cette recette ne suppose aucune bibliothèque de composants. Elle s’appuie sur deux méthodes du DOM disponibles nativement dans tous les navigateurs modernes : Element.matches() et Element.closest(). Elle convient à un panneau ouvert par un simple bouton, pas à une fenêtre modale construite avec une bibliothèque tierce, qui gère généralement son propre cycle de focus.
Le problème concret
Un bouton ouvre un panneau. L’utilisateur navigue dedans au clavier, puis ferme le panneau avec la touche Échap ou un bouton de fermeture. À cet instant, le nœud DOM qui avait le focus juste avant l’ouverture a souvent disparu de la mémoire du script : il n’a jamais été stocké, ou il l’a été dans une variable qui a été écrasée par une autre ouverture entre-temps.
Le symptôme est simple à observer : ouvrez les outils de développement, activez le suivi de document.activeElement dans la console, ouvrez puis fermez le panneau. Si le focus atterrit sur <body>, le problème est confirmé.
La méthode : retrouver le déclencheur par sa signature
Plutôt que de stocker une référence à chaque ouverture, une autre approche consiste à donner à chaque déclencheur un identifiant stable, puis à le retrouver au moment de fermer, via closest() pour remonter dans l’arbre si l’événement de fermeture part d’un élément imbriqué.

// Chaque bouton d'ouverture porte un attribut de liaison
// <button data-panel-target="filtres">Filtrer</button>
function ouvrirPanneau(panneau, declencheur) {
panneau.hidden = false;
panneau.dataset.openedBy = declencheur.dataset.panelTarget;
const premierFocusable = panneau.querySelector('button, [href], input, select, textarea');
if (premierFocusable) premierFocusable.focus();
}
function fermerPanneau(panneau) {
const id = panneau.dataset.openedBy;
panneau.hidden = true;
const declencheur = document.querySelector(`[data-panel-target="${id}"]`);
if (declencheur) declencheur.focus();
}
document.addEventListener('click', (evenement) => {
const bouton = evenement.target.closest('[data-panel-target]');
if (bouton) {
const panneau = document.getElementById(bouton.dataset.panelTarget);
ouvrirPanneau(panneau, bouton);
return;
}
const fermeture = evenement.target.closest('[data-panel-close]');
if (fermeture) {
fermerPanneau(fermeture.closest('[hidden], .panneau)'));
}
});
Pourquoi closest() plutôt qu’une simple correspondance
Element.matches() répond à une question binaire : cet élément précis correspond-il à ce sélecteur ? Element.closest() va plus loin, il remonte l’arbre du DOM depuis l’élément donné, en testant à chaque niveau matches(), jusqu’à trouver une correspondance ou atteindre la racine du document. C’est précisément ce qu’il faut quand le clic de fermeture part d’une icône SVG imbriquée dans un bouton, lui-même imbriqué dans un en-tête de panneau : l’événement se déclenche sur l’icône, mais c’est le bouton ou le panneau qui portent l’information utile.
evenement.target.matches('[data-panel-close]')échoue si le clic touche l’icône interneevenement.target.closest('[data-panel-close]')retrouve le bon élément quel que soit le niveau de clic- La délégation d’événements sur
documentévite d’attacher un écouteur par panneau
Variante : mémoriser une pile d’ouvertures
Quand plusieurs panneaux peuvent s’empiler (un panneau qui ouvre lui-même un sous-panneau), un identifiant unique par déclencheur ne suffit plus : il faut une pile. Une variable de portée module, un tableau simple, accumule les déclencheurs successifs, et la fermeture dépile le dernier élément ajouté.
const pileDeFocus = [];
function ouvrir(panneau, declencheur) {
pileDeFocus.push(declencheur);
panneau.hidden = false;
}
function fermer(panneau) {
panneau.hidden = true;
const precedent = pileDeFocus.pop();
if (precedent) precedent.focus();
}
Cette variante évite de dépendre d’un attribut data-* et fonctionne même si deux déclencheurs partagent la même cible visuelle, tant que l’ordre d’ouverture et de fermeture reste cohérent.
Ce que cette recette ne couvre pas
Les fenêtres modales construites avec une bibliothèque (un composant de dialogue tiers, un système de routing qui gère ses propres transitions) disposent en général de leur propre mécanisme de restauration du focus, parfois configurable via une prop dédiée. Appliquer cette recette par-dessus créerait un conflit : deux scripts tenteraient de déplacer le focus au même moment. Vérifiez toujours la documentation du composant avant d’ajouter une gestion manuelle.
De même, cette recette ne traite pas le piégeage du focus à l’intérieur du panneau ouvert (empêcher la tabulation de sortir du panneau tant qu’il est ouvert) : c’est un sujet séparé, avec ses propres pièges de conception.
Une règle simple à retenir dans un projet WordPress sur mesure : chaque fonction qui masque un élément (
hidden,display: none, une classe qui déclenche une transition) devrait avoir sa fonction miroir qui restaure explicitement le focus. Ne comptez jamais sur le comportement par défaut du navigateur pour deviner où l’utilisateur doit atterrir.
En résumé
Restaurer le focus au bon endroit n’exige ni bibliothèque ni framework : deux méthodes natives du DOM, matches() et closest(), suffisent à retrouver de façon fiable l’élément qui a ouvert un panneau, même quand l’événement de fermeture part d’un nœud imbriqué plusieurs niveaux plus bas. La clé tient en une phrase : chaque ouverture doit savoir comment se refermer proprement, et cela commence par ne jamais perdre la trace du déclencheur.