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>
);
}