Retour au catalogue

FAQ Accordion

FAQ classique en accordion avec animation d'ouverture/fermeture. Un seul item ouvert a la fois.

faqsimple Both Responsive a11y
minimalcorporatesaasagencyuniversalstacked
Theme

How to build an animated FAQ accordion in React

A React FAQ accordion that allows only one item open at a time stores the active index in a single useState, then wraps each answer in Framer Motion's AnimatePresence to animate from height 0 to 'auto' on mount and back to 0 on unmount, giving a smooth expand/collapse without measuring DOM elements manually.

  • Stack: React 18 + Framer Motion 11 + Tailwind v4 + lucide-react, ~140 lines.
  • Single open item enforced via one useState<number | null>, no context, no reducer.
  • Height animation uses AnimatePresence with initial={false} to skip the opening animation on first render.
  • Accessible: each trigger is a native <button>, readable by screen readers without ARIA hacks.
  • Responsive out of the box, centered max-w-3xl column, works on all viewport widths.

FAQ Accordion is a React section component that lists questions vertically and expands one answer at a time with a smooth animated height transition. The Plus icon rotates 45 degrees to become a close cross, and the answer slides down using Framer Motion's AnimatePresence so the collapse is as clean as the expand. No external accordion library needed.

Anatomy

The section wraps a max-w-3xl centered column. At the top sits a motion.div header with an optional badge, an h2 title, and an optional subtitle, all animated into view on scroll with a single whileInView. Below it, a flex column lists every FAQ item: each item is itself a motion.div that fades and slides up on scroll (staggered by 40ms per index). Inside each item, a full-width button aligns the question text left and a circular icon div right. The AnimatePresence block below the button holds the answer paragraph.

How it works

The height animation is the core technique. Framer Motion can animate to `height: 'auto'`, something CSS transitions cannot do natively, by internally measuring the element after mount. The answer div starts at `height: 0, opacity: 0`, animates to `height: 'auto', opacity: 1`, then exits back to `height: 0, opacity: 0`. Setting `initial={false}` on AnimatePresence prevents all items from animating closed on first render. The icon rotation is a separate `motion.div` with `animate={{ rotate: isOpen ? 45 : 0 }}`, turning the Plus into an X without swapping components.

How to build it in React

  1. Set up state and data

    Declare a single useState<number | null>(null) to track which item is open. A null value means everything is closed. Pass items as a prop typed as `{ question: string; answer: string }[]` so the component stays purely presentational.

    const [openIndex, setOpenIndex] = useState<number | null>(null);
  2. Wire the toggle button

    Each item renders a full-width button. Clicking it either opens the item (sets its index) or closes it (resets to null). This single-expression handler ensures only one item stays open at a time without any extra logic.

    <button onClick={() => setOpenIndex(isOpen ? null : i)}>
  3. Animate height with AnimatePresence

    Wrap the answer in AnimatePresence with `initial={false}`. Conditionally render a motion.div only when isOpen is true. Animate from `{ height: 0, opacity: 0 }` to `{ height: 'auto', opacity: 1 }` and back. The overflow-hidden class on the div prevents the content from peeking out during the collapse.

    <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>
  4. Rotate the icon

    Rather than swapping between a Plus and an X icon, wrap the Plus in a motion.div and animate its rotation to 45 degrees when the item is open. Simultaneously transition the background color from a muted surface to the accent color so the icon visually confirms the active state.

    <motion.div animate={{ rotate: isOpen ? 45 : 0 }} transition={{ duration: 0.2 }}>
      <Plus size={14} />
    </motion.div>

When to use it

Use this accordion on any page that needs to surface 5-10 questions without overwhelming the layout: pricing pages (objection handling), product pages (spec details), support landing pages, or the bottom of a SaaS marketing page. Avoid it when you have only 2-3 items, a flat list reads faster. Also avoid it when answers are very short (one sentence); showing everything expanded is cleaner at that point.

Used by

  • Stripe, Uses a collapsible FAQ section at the bottom of its pricing page to handle billing edge-case questions without bloating the main content.
  • Linear, FAQ accordion below the pricing tiers to address plan comparison questions inline, keeping users on the page.
  • Vercel, Collapsible FAQ block on the pricing page covering billing, limits, and enterprise specifics.
  • Notion, Accordion-style FAQ on the pricing page to consolidate common upgrade and plan questions in minimal space.

FAQ

Can multiple items be open at the same time?

Not in this variant, it enforces a single-open model by design. To allow multiple open items, replace useState<number | null> with useState<Set<number>>, check the set instead of comparing indexes, and update it by toggling values in and out.

Why use Framer Motion for the height animation instead of CSS?

CSS cannot animate from height 0 to height auto, you have to use max-height with a hardcoded guess, which causes inconsistent timing across items of different lengths. Framer Motion measures the real height after mount and animates to that exact value, so the duration is consistent regardless of answer length.

How do I add rich content (code blocks, links) inside an answer?

Change the `answer` field from a string to a React.ReactNode and render it with `{item.answer}` directly instead of wrapping it in a paragraph. The AnimatePresence height animation works with any content inside, including nested components.

Does the stagger on scroll affect SEO?

No. The stagger is purely visual (opacity/translateY via whileInView) and the text content is fully present in the DOM from the initial HTML. Search engine crawlers read the DOM, not computed visual states, so all questions and answers are indexed.

"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus } from "lucide-react";

interface FaqItem {
  question: string;
  answer: string;
}

interface FaqAccordionProps {
  badge?: string;
  title?: string;
  subtitle?: string;
  items?: FaqItem[];
}

const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];

export default function FaqAccordion({
  badge = "FAQ",

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React FAQ Accordion with Framer Motion, Code + Tutorial