Skip to content
← 1001 Widgets
N° 014 · ●○○

FAQ accordion

Questions that open one by one, by click or by keyboard. The right exercise to learn to show and hide content without losing anyone along the way.

HTMLCSSJS
What you'll learn
  • Link a button to its panel with aria-expanded
  • Animate an unknown height with CSS Grid
  • Navigate with the arrow keys
Why this language

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.

How it works
  1. 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>
  2. 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; }
  3. 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);
    }
  4. 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');
    });
  5. 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();
    });
Pitfalls

PIÈGE 1 : Une div cliquable

Pitfall

Mauvais : <div onclick="ouvrir()">. Le clavier ne peut pas l'atteindre, et un lecteur d'écran n'annonce rien.

Fix

Correct : un vrai <button>, qui gère seul le focus, Entrée et Espace.

PIÈGE 2 : Animer height: auto

Pitfall

Mauvais : transition sur height de 0 à auto. Le navigateur ne sait pas animer vers auto, la réponse apparaît d'un coup.

Fix

Correct : grid-template-rows de 0fr à 1fr, avec overflow: hidden sur l'enfant.

PIÈGE 3 : Oublier l'état annoncé

Pitfall

Mauvais : ne changer que la classe CSS. Visuellement c'est ouvert, mais aria-expanded dit toujours false.

Fix

Correct : mettre à jour aria-expanded dans la même fonction que la classe.

PERSONNALISER
  • 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.
OPTIMISATION
  • 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.
VERS REACT

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.

Comments
0/1000

Loading comments…

Audience measurement

With your consent, this site measures its traffic with Google Analytics and the Meta pixel, via Google Tag Manager. Nothing is loaded until you choose, and you can change your mind at any time with the "Cookies" link at the bottom of the page.