FAQ Accordion
Accessible accordion with smooth height animation and rotating icon.
Frequently asked questions
Yes. Everything is MIT licensed — use it in personal, client and commercial work without attribution.
components/ui/faq-accordion.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Plus } from "lucide-react";
import { useState } from "react";
type FaqItem = { question: string; answer: string };
const faqs: FaqItem[] = [
{ question: "Can I use the components in commercial projects?", answer: "Yes. Everything is MIT licensed — use it in personal, client and commercial work without attribution." },
{ question: "Which frameworks are supported?", answer: "Components are written in React with TypeScript and Tailwind CSS. They work in Next.js, Vite, Remix and Astro." },
{ question: "Do I need to install a package?", answer: "No. Copy the source into your project and own it. The only dependencies are motion and lucide-react." },
{ question: "Are the components accessible?", answer: "We use semantic HTML, visible focus states and respect reduced-motion settings wherever animation matters." },
];
export function FaqAccordion({ items = faqs }: { items?: FaqItem[] }) {
const [open, setOpen] = useState<number | null>(0);
return (
<div className="divide-y divide-zinc-200 rounded-2xl border border-zinc-200 bg-white">
{items.map((item, i) => {
const isOpen = open === i;
return (
<div key={item.question}>
<button
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 px-6 py-5 text-left font-medium text-zinc-900 transition-colors hover:bg-zinc-50/70"
>
{item.question}
<motion.span
animate={{ rotate: isOpen ? 45 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 22 }}
className={`grid size-7 shrink-0 place-items-center rounded-full border transition-colors ${isOpen ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-200 text-zinc-500"}`}
>
<Plus className="size-4" />
</motion.span>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<p className="px-6 pb-5 text-sm leading-relaxed text-zinc-500">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
);
}
export default function Demo() {
return (
<div className="w-full max-w-2xl">
<h2 className="mb-8 text-center text-3xl font-semibold tracking-tight text-zinc-900">Frequently asked questions</h2>
<FaqAccordion />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/faq-accordion.tsxcomponents/ui/faq-accordion.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Plus } from "lucide-react"; import { useState } from "react"; type FaqItem = { question: string; answer: string }; const faqs: FaqItem[] = [ { question: "Can I use the components in commercial projects?", answer: "Yes. Everything is MIT licensed — use it in personal, client and commercial work without attribution." }, { question: "Which frameworks are supported?", answer: "Components are written in React with TypeScript and Tailwind CSS. They work in Next.js, Vite, Remix and Astro." }, { question: "Do I need to install a package?", answer: "No. Copy the source into your project and own it. The only dependencies are motion and lucide-react." }, { question: "Are the components accessible?", answer: "We use semantic HTML, visible focus states and respect reduced-motion settings wherever animation matters." }, ]; export function FaqAccordion({ items = faqs }: { items?: FaqItem[] }) { const [open, setOpen] = useState<number | null>(0); return ( <div className="divide-y divide-zinc-200 rounded-2xl border border-zinc-200 bg-white"> {items.map((item, i) => { const isOpen = open === i; return ( <div key={item.question}> <button onClick={() => setOpen(isOpen ? null : i)} aria-expanded={isOpen} className="flex w-full items-center justify-between gap-6 px-6 py-5 text-left font-medium text-zinc-900 transition-colors hover:bg-zinc-50/70" > {item.question} <motion.span animate={{ rotate: isOpen ? 45 : 0 }} transition={{ type: "spring", stiffness: 300, damping: 22 }} className={`grid size-7 shrink-0 place-items-center rounded-full border transition-colors ${isOpen ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-200 text-zinc-500"}`} > <Plus className="size-4" /> </motion.span> </button> <AnimatePresence initial={false}> {isOpen && ( <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }} className="overflow-hidden" > <p className="px-6 pb-5 text-sm leading-relaxed text-zinc-500">{item.answer}</p> </motion.div> )} </AnimatePresence> </div> ); })} </div> ); } export default function Demo() { return ( <div className="w-full max-w-2xl"> <h2 className="mb-8 text-center text-3xl font-semibold tracking-tight text-zinc-900">Frequently asked questions</h2> <FaqAccordion /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.