freeNew247 lines · framer-motion
FAQ Accordion
One question open, one clear answer, every time. A full-width FAQ section: a centered headline and subcopy above a vertical list of question rows that expand into their answer with a smooth height animation and a rotating chevron.
- Use it for
- A pricing, security or support FAQ block near the bottom of a landing page, before the final CTA or footer.
- Themes
- Light + dark
- License
- Commercial use
Live
FAQ
Questions, answered
Everything worth knowing before you commit — pricing, security, integrations, and what happens if you change your mind.
Every plan includes the full component library and every update as new blocks ship, on the same build tooling. Paid tiers add more export options, private components, and a faster support queue — the underlying product is otherwise identical.
tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width FAQ section: a centered headline + subcopy above a vertical list of
* question/answer rows. Each row is a real <button> that expands its answer with a
* smooth height animation; by default only one row is open at a time, but
* `allowMultiple` lets several stay open together.
*/
export interface FaqAccordionItem {
question: string;
answer: string;
}
export interface FaqAccordionProps {
eyebrow?: string;
title?: string;
subcopy?: string;
items?: FaqAccordionItem[];
/** Allow more than one answer to stay open at the same time. */
allowMultiple?: boolean;
/** Index of the row open on mount, or null to start with everything closed. */
defaultOpenIndex?: number | null;
/** Visual style of the expand/collapse control. */
iconStyle?: "chevron" | "plus";
className?: string;
}
const DEFAULT_ITEMS: FaqAccordionItem[] = [
{
question: "What's included in each plan?",
answer:
"Every plan includes the full component library and every update as new blocks ship, on the same build tooling. Paid tiers add more export options, private components, and a faster support queue — the underlying product is otherwise identical.",
},
{
question: "Can I change plans later?",
answer:
"Yes. You can move up or down at any point from your account settings. Changes apply at the start of your next billing cycle, and you keep access to everything you've already built with.",
},
{
question: "How is my data secured?",
answer:
"All traffic is encrypted in transit, and anything stored with us is encrypted at rest. Access to production systems is limited to a small set of engineers and logged, and we run regular reviews of our own infrastructure.",
},
{
question: "Do you integrate with the tools we already use?",
answer:
"The library ships as plain components for your existing stack, so it drops into whatever framework and design system you already run — no separate runtime, no proprietary build step, and no lock-in to a particular hosting provider.",
},
{
question: "What happens if I cancel?",
answer:
"Your subscription stops renewing and you keep access until the end of the period you already paid for. Code you've already shipped keeps working — cancelling only stops future billing, it doesn't revoke what you've built.",
},
{
question: "Is there a free tier?",
answer:
"Yes. The free tier includes a working set of components with no time limit, so you can evaluate the library on a real project before deciding whether to upgrade.",
},
{
question: "How do I get support if something breaks?",
answer:
"Reach out through the support channel linked in your dashboard. We triage by impact, and paid plans get a faster response window along with direct access to the team that builds the components.",
},
];
function ToggleIcon({
open,
variant,
reduce,
}: {
open: boolean;
variant: "chevron" | "plus";
reduce: boolean | null;
}) {
const transition = { duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] as const };
if (variant === "plus") {
return (
<span className="relative inline-flex size-5 shrink-0 items-center justify-center text-signal">
<span className="absolute h-[1.5px] w-3 rounded-full bg-current" aria-hidden />
<motion.span
className="absolute h-[1.5px] w-3 rounded-full bg-current"
aria-hidden
initial={false}
animate={{ rotate: open ? 0 : 90, opacity: open ? 0 : 1 }}
transition={transition}
/>
</span>
);
}
return (
<motion.svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="size-5 shrink-0 text-signal"
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={transition}
>
<path d="m6 9 6 6 6-6" />
</motion.svg>
);
}
function AccordionRow({
item,
index,
isOpen,
onToggle,
iconStyle,
reduce,
}: {
item: FaqAccordionItem;
index: number;
isOpen: boolean;
onToggle: () => void;
iconStyle: "chevron" | "plus";
reduce: boolean | null;
}) {
const buttonId = `faq-accordion-trigger-${index}`;
const panelId = `faq-accordion-panel-${index}`;
const transition = { duration: reduce ? 0 : 0.32, ease: [0.16, 1, 0.3, 1] as const };
return (
<div>
<h3 className="contents">
<button
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={onToggle}
className="flex w-full items-center justify-between gap-4 py-5 text-left outline-none focus-visible:rounded-lg"
>
<span className="min-w-0 flex-1 font-display text-base font-semibold text-foreground sm:text-lg">
{item.question}
</span>
<ToggleIcon open={isOpen} variant={iconStyle} reduce={reduce} />
</button>
</h3>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="panel"
id={panelId}
role="region"
aria-labelledby={buttonId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={transition}
style={{ overflow: "hidden" }}
>
<p className="pb-5 pr-8 text-sm leading-relaxed text-muted-foreground sm:text-[15px]">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function FaqAccordion({
eyebrow = "FAQ",
title = "Questions, answered",
subcopy = "Everything worth knowing before you commit — pricing, security, integrations, and what happens if you change your mind.",
items = DEFAULT_ITEMS,
allowMultiple = false,
defaultOpenIndex = 0,
iconStyle = "chevron",
className,
}: FaqAccordionProps) {
const reduce = useReducedMotionSafe();
const [openIndexes, setOpenIndexes] = React.useState<Set<number>>(
() => new Set(defaultOpenIndex === null || defaultOpenIndex === undefined ? [] : [defaultOpenIndex]),
);
const toggle = (index: number) => {
setOpenIndexes((prev) => {
const next = new Set(allowMultiple ? prev : []);
if (prev.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
return (
<section className={cn("w-full bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
<motion.div
initial={reduce ? false : { opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.4 }}
transition={{ duration: 0.5 }}
className="mx-auto max-w-2xl text-center"
>
<p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-3 font-display text-[clamp(2rem,5vw,3.25rem)] font-extrabold leading-[0.97] tracking-[-0.03em] text-foreground">
{title}
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>
</motion.div>
<motion.div
initial={reduce ? false : { opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="mx-auto mt-12 max-w-3xl divide-y divide-border rounded-3xl border border-border bg-card px-5 sm:px-8"
>
{items.map((item, index) => (
<AccordionRow
key={item.question}
item={item}
index={index}
isOpen={openIndexes.has(index)}
onToggle={() => toggle(index)}
iconStyle={iconStyle}
reduce={reduce}
/>
))}
</motion.div>
</section>
);
}
export default FaqAccordion;
How it behaves
- →Each row is a real <button>; clicking it toggles its answer panel open/closed via framer-motion AnimatePresence animating height from 0 to "auto" plus a fade, not AnimateSharedLayout.
- →By default only one row is open at a time (opening a new row closes the previous one); the allowMultiple control switches to independent per-row state so several answers can stay open.
- →The trigger icon (chevron or plus, via iconStyle) rotates 180°/90° in sync with the open state using the same transition as the panel.
- →defaultOpenIndex sets which row (if any) starts open on mount.
- →Header and the list card each fade/slide in once when they scroll into view (whileInView, viewport={{ once: true }}), so the animation never replays on re-scroll.
- →prefers-reduced-motion (via useReducedMotion) sets every transition duration to 0 and skips the scroll-in entrance, so panels snap open/closed and content is visible immediately.
Accessibility
- ✓Each trigger is a native <button> with `aria-expanded` reflecting open state and `aria-controls` pointing at its panel id.
- ✓Each panel has `role="region"` and `aria-labelledby` pointing back at its trigger button, and the button sits inside an <h3> (via a `display:contents` wrapper) so screen-reader users get proper heading navigation without changing the visual layout.
- ✓Decorative chevron/plus icons are `aria-hidden`; focus is kept on the native button so keyboard and screen-reader focus order matches the visual order.
- ✓Honours prefers-reduced-motion.