Accordéon FAQ
Des questions qui s'ouvrent une à une, au clic comme au clavier. Le bon exercice pour apprendre à montrer et cacher du contenu sans perdre personne en route.
- Relier un bouton à sa zone avec aria-expanded
- Animer une hauteur inconnue avec CSS Grid
- Naviguer au clavier avec les flèches
Pourquoi un accordéon ? Parce que toutes les FAQ en ont un, et que la plupart sont mal faits : des div cliquables que le clavier ignore, et des lecteurs d'écran qui ne savent pas si la réponse est ouverte.
Ici, chaque question est un vrai bouton. Il dit s'il est ouvert avec aria-expanded, et il pointe vers sa réponse avec aria-controls.
Pourquoi en JavaScript simple ? L'ouverture se fait en CSS, avec une astuce de grille qui anime même une hauteur inconnue. Le JavaScript ne fait que basculer un attribut.
LE HTML
Un bouton dans un titre, une zone reliée par aria-controls.
<div class="item"> <h3><button class="question" aria-expanded="false" aria-controls="r1">Combien de temps ?</button></h3> <div class="reponse" id="r1" role="region"> <div><p>Deux à six semaines.</p></div> </div> </div>LE CSS
grid-template-rows passe de 0fr à 1fr : la hauteur s'anime sans connaître la taille du texte.
.reponse { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .35s; } .reponse > div { overflow: hidden; } .item.ouvert .reponse { grid-template-rows: 1fr; }OUVRIR ET FERMER
On change l'attribut et la classe ensemble : l'état visible et l'état annoncé restent d'accord.
function basculer(bouton, ouvrir) { bouton.setAttribute('aria-expanded', String(ouvrir)); bouton.closest('.item').classList.toggle('ouvert', ouvrir); }UN SEUL ÉCOUTEUR
La délégation suffit pour toutes les questions, même celles ajoutées plus tard.
const boutons = [...document.querySelectorAll('.question')]; document.querySelector('.faq').addEventListener('click', e => { const b = e.target.closest('.question'); if (!b) return; basculer(b, b.getAttribute('aria-expanded') !== 'true'); });LE CLAVIER
Flèches pour passer d'une question à l'autre, Début et Fin pour les extrémités.
document.querySelector('.faq').addEventListener('keydown', e => { const i = boutons.indexOf(document.activeElement); if (i < 0) return; const cible = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: boutons.length - 1 }[e.key]; if (cible === undefined) return; e.preventDefault(); boutons[(cible + boutons.length) % boutons.length].focus(); });
PIÈGE 1 : Une div cliquable
Mauvais : <div onclick="ouvrir()">. Le clavier ne peut pas l'atteindre, et un lecteur d'écran n'annonce rien.
Correct : un vrai <button>, qui gère seul le focus, Entrée et Espace.
PIÈGE 2 : Animer height: auto
Mauvais : transition sur height de 0 à auto. Le navigateur ne sait pas animer vers auto, la réponse apparaît d'un coup.
Correct : grid-template-rows de 0fr à 1fr, avec overflow: hidden sur l'enfant.
PIÈGE 3 : Oublier l'état annoncé
Mauvais : ne changer que la classe CSS. Visuellement c'est ouvert, mais aria-expanded dit toujours false.
Correct : mettre à jour aria-expanded dans la même fonction que la classe.
- Une case « une seule question ouverte » : avant d'ouvrir, fermez toutes les autres.
- Une icône qui tourne : un + dans un carré, remplacé par un − quand la question est ouverte.
- Un lien direct vers une question : lisez location.hash au chargement et ouvrez la question correspondante.
- L'élément natif <details> et <summary> fait déjà l'essentiel sans JavaScript : partez de lui si vous n'avez pas besoin d'animation.
- Avec beaucoup de questions, ajoutez un champ de recherche qui filtre avant l'affichage.
L'index de la question ouverte va dans un useState.
Chaque question devient un composant qui reçoit ouvert et onToggle en props.
useId génère les identifiants qui relient le bouton et sa réponse.
Chargement des commentaires…