Retour au catalogue

FAQ Accordion Animated

Accordion FAQ premium avec lignes separatrices en scaleX staggere, hauteur animee fluide (height auto), icone + qui pivote a 45 degres et fond actif sur l'item ouvert. Style Apple / Linear.

faqmedium Both Responsive a11y
elegantminimalcorporatesaasagencyportfoliouniversalstacked
Theme
"use client";

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

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

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

const EASE = [0.16, 1, 0.3, 1] as const;

function AccordionItem({
  item,
  index,
  isOpen,
  onToggle,
}: {
  item: FaqItem;
  index: number;
  isOpen: boolean;
  onToggle: () => void;
}) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 16 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-30px" }}
      transition={{ duration: 0.5, delay: index * 0.06, ease: EASE }}
    >
      {/* Separator line — scaleX entrance */}
      <motion.div
        initial={{ scaleX: 0 }}
        whileInView={{ scaleX: 1 }}
        viewport={{ once: true }}
        transition={{ duration: 0.55, delay: index * 0.06 + 0.1, ease: EASE }}
        style={{
          transformOrigin: "left",
          height: "1px",
          background: "var(--color-border)",
        }}
      />

      <motion.div
        animate={{
          background: isOpen
            ? "var(--color-background-card)"
            : "transparent",
        }}
        transition={{ duration: 0.25 }}
        style={{
          borderRadius: isOpen ? "var(--radius-md, 0.75rem)" : "0",
          overflow: "hidden",
        }}
      >
        {/* Trigger */}
        <button
          onClick={onToggle}
          aria-expanded={isOpen}
          style={{
            width: "100%",
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            gap: "1.5rem",
            padding: isOpen ? "1.375rem 1.25rem 0.875rem" : "1.375rem 0",
            background: "transparent",
            border: "none",
            cursor: "pointer",
            textAlign: "left",
            transition: "padding 0.25s",
          }}
        >
          <span
            style={{
              fontSize: "1rem",
              fontWeight: isOpen ? 600 : 500,
              lineHeight: 1.4,
              color: isOpen
                ? "var(--color-foreground)"
                : "var(--color-foreground-muted)",
              transition: "color 0.2s, font-weight 0.2s",
            }}
          >
            {item.question}
          </span>

          {/* Animated + → × icon */}
          <motion.div
            animate={{
              rotate: isOpen ? 45 : 0,
              background: isOpen
                ? "var(--color-accent)"
                : "var(--color-background-alt, var(--color-background))",
            }}
            transition={{ duration: 0.28, ease: EASE }}
            style={{
              flexShrink: 0,
              width: "2rem",
              height: "2rem",
              borderRadius: "var(--radius-full, 9999px)",
              border: "1px solid var(--color-border)",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
            }}
          >
            <svg
              width="14"
              height="14"
              viewBox="0 0 14 14"
              fill="none"
              aria-hidden="true"
            >
              <motion.line
                x1="7" y1="1" x2="7" y2="13"
                stroke={isOpen ? "var(--color-background)" : "var(--color-foreground-muted)"}
                strokeWidth="1.75"
                strokeLinecap="round"
              />
              <motion.line
                x1="1" y1="7" x2="13" y2="7"
                stroke={isOpen ? "var(--color-background)" : "var(--color-foreground-muted)"}
                strokeWidth="1.75"
                strokeLinecap="round"
              />
            </svg>
          </motion.div>
        </button>

        {/* Answer panel — height auto animation */}
        <AnimatePresence initial={false}>
          {isOpen && (
            <motion.div
              key="answer"
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={{ height: 0, opacity: 0 }}
              transition={{
                height: { duration: 0.38, ease: EASE },
                opacity: { duration: 0.28, ease: EASE },
              }}
              style={{ overflow: "hidden" }}
            >
              <motion.p
                initial={{ y: -8 }}
                animate={{ y: 0 }}
                exit={{ y: -6 }}
                transition={{ duration: 0.3, ease: EASE }}
                style={{
                  padding: "0 1.25rem 1.375rem",
                  fontSize: "0.9375rem",
                  lineHeight: 1.7,
                  color: "var(--color-foreground-muted)",
                }}
              >
                {item.answer}
              </motion.p>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
    </motion.div>
  );
}

export default function FaqAccordionAnimated({
  badge = "FAQ",
  title = "Questions frequentes",
  subtitle = "",
  items = [],
}: FaqAccordionAnimatedProps) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);

  return (
    <section
      style={{
        paddingTop: "var(--section-padding-y, 5rem)",
        paddingBottom: "var(--section-padding-y, 5rem)",
        background: "var(--color-background)",
      }}
    >
      <div
        style={{
          maxWidth: "42rem",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
        }}
      >
        {/* Header */}
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.6, ease: EASE }}
          style={{ textAlign: "center", marginBottom: "3.5rem" }}
        >
          {badge && (
            <span
              style={{
                display: "inline-block",
                marginBottom: "1rem",
                fontSize: "0.6875rem",
                fontWeight: 700,
                letterSpacing: "0.12em",
                textTransform: "uppercase",
                color: "var(--color-accent)",
              }}
            >
              {badge}
            </span>
          )}
          <h2
            style={{
              fontFamily: "var(--font-sans)",
              fontSize: "clamp(1.875rem, 4vw, 2.75rem)",
              fontWeight: 700,
              letterSpacing: "-0.03em",
              color: "var(--color-foreground)",
              lineHeight: 1.15,
            }}
          >
            {title}
          </h2>
          {subtitle && (
            <p
              style={{
                marginTop: "0.875rem",
                fontSize: "1rem",
                lineHeight: 1.6,
                color: "var(--color-foreground-muted)",
              }}
            >
              {subtitle}
            </p>
          )}
        </motion.div>

        {/* Accordion list */}
        <div style={{ display: "flex", flexDirection: "column" }}>
          {items.map((item, i) => (
            <AccordionItem
              key={i}
              item={item}
              index={i}
              isOpen={openIndex === i}
              onToggle={() => setOpenIndex(openIndex === i ? null : i)}
            />
          ))}
          {/* Bottom border */}
          <motion.div
            initial={{ scaleX: 0 }}
            whileInView={{ scaleX: 1 }}
            viewport={{ once: true }}
            transition={{
              duration: 0.55,
              delay: items.length * 0.06 + 0.1,
              ease: EASE,
            }}
            style={{
              transformOrigin: "left",
              height: "1px",
              background: "var(--color-border)",
            }}
          />
        </div>
      </div>
    </section>
  );
}

Avis

FAQ Accordion Animated — React Faq Section — Incubator