A button-controlled FAQ accordion for pricing pages, product objections, landing pages, and conversion-focused answer sections.

Animated FAQ is a button-controlled accordion for objections that appear at the decision point.
Use it on pricing, landing, product, and comparison pages where the visitor needs a specific answer before clicking the CTA. The page job is objection handling: questions open cleanly, answers remain crawlable, and the interaction clarifies state.
In production, the risk is turning answers into hidden decoration. Each question needs a real button, aria-expanded, a labelled answer region, and a reduced-motion state that opens instantly.
npx hyperiux add animated-faqimport {
FAQContent,
FAQGroup,
FAQTitle,
FAQWrapper,
} from "@/components/effects/animated-faq";
export default function FAQDemo() {
const defaultOpenItems = faqItems
.filter((item) => item.defaultOpen)
.map((item) => item.id);
return (
<>
<section className="bg-black h-screen px-8 py-20 text-white">
<div className="max-w-5xl mx-auto text-center text-white mb-12">
{/* Heading */}
<h1 className="text-[5.5vw] max-sm:text-[11vw] max-md:text-[8vw]">
Animated Faq
</h1>
{/* Subtext */}
<p className="mt-8 text-[1.2vw] max-sm:text-[4.5vw] max-md:mt-4 max-md:text-[3vw]">
Click on the FAQ triggers
</p>
</div>
<div className="mx-auto max-w-4xl space-y-4">
<FAQGroup allowMultiple={false} defaultOpenItems={defaultOpenItems}>
{faqItems.map((item) => (
<FAQWrapper
key={item.id}
itemId={item.id}
className="rounded-md border border-white/20 px-6 py-5"
titleClassName="text-[1.1rem] font-medium text-white"
iconSize={16}
iconStrokeWidth={2}
duration={0.5}
>
<FAQTitle className="pb-0">{item.title}</FAQTitle>
<FAQContent className="pt-4 text-neutral-400">
{item.content}
</FAQContent>
</FAQWrapper>
))}
</FAQGroup>
</div>
</section>
</>
);
}
const faqItems = [
{
id: "faq-1",
title: "What makes this FAQ package more reusable?",
content: (
<>
You are no longer locked into a plain data object. You can pass rich
JSX, custom markup, inline links, badges, icons, or even other
components inside the title and content.
</>
),
defaultOpen: true,
},
{
id: "faq-2",
title: "Can I put custom content inside the answer?",
content: (
<div className="space-y-3">
<p>Yes. This content area accepts full React nodes, not just text.</p>
<ul className="list-disc pl-5 space-y-1">
<li>Paragraphs</li>
<li>Lists</li>
<li>Buttons</li>
<li>Inline links</li>
</ul>
</div>
),
defaultOpen: false,
},
{
id: "faq-3",
title: "Does it use the ChevronBird trigger?",
content: (
<>
Yes. The open and close state is visually driven by ChevronBird, so your
motion language stays consistent across the whole system.
</>
),
defaultOpen: false,
},
];
import { FAQContent, FAQGroup, FAQTitle, FAQWrapper, } from "./AnimatedFaqComp";
import React from "react";
export { FAQContent, FAQGroup, FAQTitle, FAQWrapper };
const faqItems = [
{
id: "faq-1",
title: "What makes this FAQ package more reusable?",
content: (<>
You are no longer locked into a plain data object. You can pass rich
JSX, custom markup, inline links, badges, icons, or even other
components inside the title and content.
</>),
defaultOpen: true,
},
{
id: "faq-2",
title: "Can I put custom content inside the answer?",
content: (<div className="space-y-3">
<p>Yes. This content area accepts full React nodes, not just text.</p>
<ul className="list-disc pl-5 space-y-1">
<li>Paragraphs</li>
<li>Lists</li>
<li>Buttons</li>
<li>Inline links</li>
</ul>
</div>),
defaultOpen: false,
},
{
id: "faq-3",
title: "Does it use the ChevronBird trigger?",
content: (<>
Yes. The open and close state is visually driven by ChevronBird, so your
motion language stays consistent across the whole system.
</>),
defaultOpen: false,
},
];
export default function AnimatedFaq() {
const defaultOpenItems = faqItems
.filter((item) => item.defaultOpen)
.map((item) => item.id);
return (<div className="mx-auto max-w-4xl space-y-4">
<FAQGroup allowMultiple={false} defaultOpenItems={defaultOpenItems}>
{faqItems.map((item) => (<FAQWrapper key={item.id} itemId={item.id} className="rounded-md border border-white/20 px-6 py-5" titleClassName="text-[1.1rem] font-medium text-white" iconSize={16} iconStrokeWidth={2} duration={0.5}>
<FAQTitle className="pb-0">{item.title}</FAQTitle>
<FAQContent className="pt-4 text-neutral-400">
{item.content}
</FAQContent>
</FAQWrapper>))}
</FAQGroup>
</div>);
}
"use client";
import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react";
import gsap from "gsap";
import { ChevronDown, ChevronRight } from "lucide-react";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
const FAQContext = createContext(null);
const FAQGroupContext = createContext(null);
const useFAQContext = () => {
const context = useContext(FAQContext);
if (!context) {
throw new Error("FAQTitle and FAQContent must be used inside FAQWrapper.");
}
return context;
};
export function FAQGroup({ children, allowMultiple = false, defaultOpenItems = [], value, onChange, }) {
const isControlled = Array.isArray(value);
const [internalOpenItems, setInternalOpenItems] = useState(defaultOpenItems);
const openItems = isControlled ? value : internalOpenItems;
const toggleItem = useCallback((itemId) => {
const next = (() => {
const isOpen = openItems.includes(itemId);
if (allowMultiple) {
return isOpen
? openItems.filter((id) => id !== itemId)
: [...openItems, itemId];
}
return isOpen ? [] : [itemId];
})();
if (!isControlled) {
setInternalOpenItems(next);
}
onChange?.(next);
}, [allowMultiple, isControlled, onChange, openItems]);
const contextValue = useMemo(() => ({
allowMultiple,
openItems,
toggleItem,
}), [allowMultiple, openItems, toggleItem]);
return (<FAQGroupContext.Provider value={contextValue}>
{children}
</FAQGroupContext.Provider>);
}
export function FAQWrapper({ children, className = "", titleClassName = "", contentClassName = "", iconClassName = "", iconSize = 18, iconStrokeWidth = 2, duration = 0.45, defaultOpen = false, controlledOpen, onToggle, itemId, }) {
const group = useContext(FAQGroupContext);
const generatedId = useId();
const resolvedItemId = itemId ?? generatedId;
const isStandaloneControlled = typeof controlledOpen === "boolean";
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isOpen = group
? group.openItems.includes(resolvedItemId)
: isStandaloneControlled
? controlledOpen
: internalOpen;
const contentOuterRef = useRef(null);
const contentInnerRef = useRef(null);
const openHeightRef = useRef(0);
const reduceMotionRef = useRef(typeof window !== "undefined" &&
(window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false));
const contentId = useId();
const buttonId = useId();
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq)
return;
const onChange = (event) => {
reduceMotionRef.current = event.matches;
};
reduceMotionRef.current = mq.matches;
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
const handleToggle = () => {
if (group) {
group.toggleItem(resolvedItemId);
onToggle?.(!isOpen);
return;
}
if (isStandaloneControlled) {
onToggle?.(!controlledOpen);
return;
}
setInternalOpen((prev) => {
const next = !prev;
onToggle?.(next);
return next;
});
};
const handleWrapperClick = (event) => {
const isInteractive = event.target.closest("a, button, input, textarea, select");
if (isInteractive)
return;
handleToggle();
};
const handleKeyDown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleToggle();
}
};
useIsomorphicLayoutEffect(() => {
if (!contentOuterRef.current || !contentInnerRef.current)
return;
const outer = contentOuterRef.current;
const inner = contentInnerRef.current;
gsap.killTweensOf([outer, inner]);
const tweenDuration = reduceMotionRef.current ? 0 : duration;
if (isOpen) {
gsap.set(outer, {
overflow: "hidden",
height: "auto",
});
const targetHeight = outer.scrollHeight;
openHeightRef.current = targetHeight;
gsap.set(outer, { height: 0 });
gsap.fromTo(outer, { height: 0 }, {
height: targetHeight,
duration: tweenDuration,
ease: "power3.out",
onComplete: () => {
gsap.set(outer, {
height: "auto",
overflow: "visible",
});
},
});
}
else {
gsap.set(outer, { overflow: "hidden" });
gsap.fromTo(outer, { height: openHeightRef.current }, {
height: 0,
duration: tweenDuration,
ease: "power3.out",
});
}
}, [isOpen, duration]);
const value = {
isOpen,
contentOuterRef,
contentInnerRef,
contentId,
buttonId,
titleClassName,
contentClassName,
iconClassName,
iconSize,
iconStrokeWidth,
};
return (<FAQContext.Provider value={value}>
<div className={`cursor-pointer ${className}`} role="button" tabIndex={0} aria-expanded={isOpen} aria-controls={contentId} id={buttonId} onClick={handleWrapperClick} onKeyDown={handleKeyDown}>
{children}
</div>
</FAQContext.Provider>);
}
export function FAQTitle({ children, className = "", showIcon = true, iconPosition = "right", iconMode = "rotate", }) {
const { isOpen, titleClassName, iconClassName, iconSize, iconStrokeWidth, } = useFAQContext();
const icon = showIcon ? (<div className={`shrink-0 ${iconClassName}`}>
{iconMode === "rotate-left-down" ? (<ChevronRight size={iconSize} strokeWidth={iconStrokeWidth} className={`transition-transform duration-300 ease-out motion-reduce:transition-none ${isOpen ? "rotate-90" : "rotate-0"}`} aria-hidden="true"/>) : (<ChevronDown size={iconSize} strokeWidth={iconStrokeWidth} className={`transition-transform duration-300 ease-out motion-reduce:transition-none ${isOpen ? "rotate-180" : "rotate-0"}`} aria-hidden="true"/>)}
</div>) : null;
return (<div className={`flex w-full items-center justify-between gap-6 ${titleClassName} ${className}`}>
{iconPosition === "left" ? (<>
{icon}
<div className="flex-1">{children}</div>
</>) : (<>
<div className="flex-1">{children}</div>
{icon}
</>)}
</div>);
}
export function FAQContent({ children, className = "", innerClassName = "", }) {
const { isOpen, contentOuterRef, contentInnerRef, contentId, buttonId, contentClassName, } = useFAQContext();
return (<div id={contentId} ref={contentOuterRef} role="region" aria-labelledby={buttonId} style={{
height: isOpen ? "auto" : 0,
overflow: isOpen ? "visible" : "hidden",
}} className={contentClassName}>
<div ref={contentInnerRef} className={className}>
<div className={innerClassName}>{children}</div>
</div>
</div>);
}
export const AnimatedFAQ = {
Group: FAQGroup,
Wrapper: FAQWrapper,
Title: FAQTitle,
Content: FAQContent,
};
export default AnimatedFAQ;
A pricing page can place Animated FAQ below plan cards so questions about contracts, refunds, implementation, and support open exactly where hesitation appears.
Not for hiding essential policy, pricing, support, or legal details that users must see without opening an accordion.
Keep answer panels light, avoid layout-heavy height animation across many open items, and render answer copy as normal HTML so search and no-JS paths still see it.
Each question needs a real button with aria-expanded and a connected answer panel. On mobile, keep tap targets generous and make open/closed state visually obvious.
| Prop | Type | Default | Description |
|---|---|---|---|
allowMultiple | boolean | false | Lets more than one FAQ answer stay open at once. |
duration | number | 0.5 | Open and close animation duration. |
showIcon | boolean | true | Shows or hides the FAQ trigger icon. |
iconPosition | string | right | Side where the trigger icon is rendered. |
iconMode | string | rotate | Chevron rotation style used by the trigger icon. |
Use a button-controlled accordion: each question is a button, aria-expanded reflects state, and the answer is associated with the control. Do not use generic div toggles.
Place it near pricing, lead-capture, or product proof where visitors hesitate. It is strongest for objections, not generic educational content.
Yes. The answer text should exist as HTML so search engines, screen readers, and no-JS users can still access the information.
Yes. A custom version should define content structure, accessibility behavior, motion rules, reduced-motion behavior, source handoff, and implementation notes for the specific page job.
Need a custom effect? Tell us what to create.