Créer un accordion FAQ animé en React
Un accordion FAQ React à ouverture unique stocke l'index actif dans un seul useState, puis enveloppe chaque réponse dans AnimatePresence de Framer Motion pour animer de height 0 à 'auto' à l'ouverture et inversement à la fermeture, sans jamais mesurer manuellement les éléments du DOM.
- Stack : React 18 + Framer Motion 11 + Tailwind v4 + lucide-react, ~140 lignes.
- Un seul item ouvert à la fois via un unique useState<number | null>, sans context ni reducer.
- L'animation de hauteur utilise AnimatePresence avec initial={false} pour éviter l'animation d'ouverture au premier rendu.
- Accessible : chaque déclencheur est un <button> natif, lu par les lecteurs d'écran sans bidouille ARIA.
- Responsive nativement, colonne centrée en max-w-3xl, fonctionne sur toutes les largeurs d'écran.
FAQ Accordion est un composant de section React qui liste des questions verticalement et déploie une réponse à la fois avec une transition de hauteur fluide. L'icône Plus pivote de 45 degrés pour se transformer en croix de fermeture, et la réponse glisse vers le bas via AnimatePresence de Framer Motion pour que le repli soit aussi propre que l'ouverture. Aucune librairie d'accordion externe requise.
Anatomie
La section enveloppe une colonne centrée en max-w-3xl. En haut se trouve un motion.div d'en-tête avec un badge optionnel, un titre h2 et un sous-titre optionnel, tous animés à l'entrée dans la vue avec un seul whileInView. En dessous, une colonne flex liste chaque item FAQ : chaque item est lui-même un motion.div qui apparaît en fondu et remonte au scroll (décalé de 40ms par index). À l'intérieur, un bouton pleine largeur aligne le texte de la question à gauche et un div d'icône circulaire à droite. Le bloc AnimatePresence sous le bouton contient le paragraphe de réponse.
Comment ça marche
L'animation de hauteur est la technique centrale. Framer Motion peut animer vers `height: 'auto'`, ce que les transitions CSS ne peuvent pas faire nativement, en mesurant l'élément après le montage. Le div de réponse part de `height: 0, opacity: 0`, anime vers `height: 'auto', opacity: 1`, puis repart vers `height: 0, opacity: 0` à la sortie. Mettre `initial={false}` sur AnimatePresence empêche tous les items d'animer leur fermeture au premier rendu. La rotation de l'icône est un `motion.div` séparé avec `animate={{ rotate: isOpen ? 45 : 0 }}`, transformant le Plus en X sans changer de composant.
Comment le coder en React
Poser l'état et les données
Déclare un unique useState<number | null>(null) pour suivre quel item est ouvert. La valeur null signifie que tout est fermé. Passe les items en prop typés `{ question: string; answer: string }[]` pour que le composant reste purement présentationnel.
const [openIndex, setOpenIndex] = useState<number | null>(null);Brancher le bouton de bascule
Chaque item rend un bouton pleine largeur. Un clic l'ouvre (définit son index) ou le ferme (remet null). Ce handler en une expression garantit qu'un seul item reste ouvert à la fois sans logique supplémentaire.
<button onClick={() => setOpenIndex(isOpen ? null : i)}>Animer la hauteur avec AnimatePresence
Enveloppe la réponse dans AnimatePresence avec `initial={false}`. Rends un motion.div conditionnellement seulement quand isOpen est true. Anime de `{ height: 0, opacity: 0 }` vers `{ height: 'auto', opacity: 1 }` et retour. La classe overflow-hidden sur le div empêche le contenu de déborder pendant le repli.
<AnimatePresence initial={false}> {isOpen && ( <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }} className="overflow-hidden" > <p>{item.answer}</p> </motion.div> )} </AnimatePresence>Faire pivoter l'icône
Plutôt que d'alterner entre une icône Plus et une croix, enveloppe le Plus dans un motion.div et anime sa rotation à 45 degrés quand l'item est ouvert. Fais aussi transitionner la couleur de fond d'une surface atténuée vers la couleur d'accent pour que l'icône confirme visuellement l'état actif.
<motion.div animate={{ rotate: isOpen ? 45 : 0 }} transition={{ duration: 0.2 }}> <Plus size={14} /> </motion.div>
Quand l'utiliser
Utilise cet accordion sur toute page qui doit afficher 5-10 questions sans surcharger la mise en page : pages de tarifs (lever les objections), pages produit (détails techniques), pages d'aide, ou en bas d'une page marketing SaaS. À éviter quand tu n'as que 2-3 items, une liste à plat se lit plus vite. À éviter aussi quand les réponses sont très courtes (une phrase) ; tout afficher déplié est plus lisible dans ce cas.
Utilisé par
- Stripe, Utilise une section FAQ repliable au bas de sa page de tarifs pour traiter les questions de cas limites de facturation sans alourdir le contenu principal.
- Linear, Accordion FAQ sous les paliers tarifaires pour répondre aux questions de comparaison de plans en ligne, gardant les utilisateurs sur la page.
- Vercel, Bloc FAQ repliable sur la page de tarifs couvrant la facturation, les limites et les spécificités entreprise.
- Notion, FAQ en accordion sur la page de tarifs pour regrouper les questions courantes sur les mises à niveau et les plans en un espace minimal.
FAQ
Peut-on avoir plusieurs items ouverts en même temps ?
Pas dans cette variante, elle impose un modèle à ouverture unique par conception. Pour autoriser plusieurs items ouverts, remplace useState<number | null> par useState<Set<number>>, vérifie l'ensemble plutôt que de comparer les index, et mets-le à jour en ajoutant/retirant des valeurs.
Pourquoi Framer Motion pour l'animation de hauteur plutôt que CSS ?
CSS ne peut pas animer de height 0 vers height auto, il faut utiliser max-height avec une valeur codée en dur, ce qui provoque un timing incohérent selon la longueur des items. Framer Motion mesure la vraie hauteur après le montage et anime vers cette valeur exacte, donc la durée est cohérente quelle que soit la longueur de la réponse.
Comment ajouter du contenu riche (blocs de code, liens) dans une réponse ?
Change le champ `answer` de string en React.ReactNode et rends-le avec `{item.answer}` directement au lieu de l'envelopper dans un paragraphe. L'animation de hauteur AnimatePresence fonctionne avec n'importe quel contenu à l'intérieur, y compris des composants imbriqués.
Le décalage au scroll affecte-t-il le SEO ?
Non. Le décalage est purement visuel (opacité/translateY via whileInView) et le contenu textuel est entièrement présent dans le DOM dès le HTML initial. Les robots des moteurs de recherche lisent le DOM, pas les états visuels calculés, donc toutes les questions et réponses sont indexées.