Searchable FAQ Accordion
A filterable FAQ list where each answer expands independently.
- Category
- Patterns
- Added
- 2026-08-24
- Deps
- 2
- Updated
- 2026-09-01
Props
This entry takes no configurable props.
"use client";
import { useId, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronDown, Search } from "lucide-react";
type Faq = { q: string; a: string };
export function SearchableFaqAccordion({ faqs }: { faqs: Faq[] }) {
const baseId = useId();
const [query, setQuery] = useState("");
const [openIds, setOpenIds] = useState<Set<number>>(new Set());
const filtered = useMemo(
() =>
faqs
.map((f, i) => ({ ...f, id: i }))
.filter(
(f) => f.q.toLowerCase().includes(query.toLowerCase()) || f.a.toLowerCase().includes(query.toLowerCase()),
),
[faqs, query],
);
function toggle(id: number) {
setOpenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="w-full max-w-md">
<div className="mb-3 flex items-center gap-2 rounded-full border border-neutral-200 px-3.5 py-2">
<Search size={13} className="text-neutral-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search FAQs..."
className="w-full bg-transparent text-sm outline-none placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col divide-y divide-neutral-200 rounded-xl border border-neutral-200">
{filtered.length === 0 ? (
<p className="px-4 py-4 text-sm text-neutral-400">No matches.</p>
) : (
filtered.map((f) => {
const panelId = `${baseId}-panel-${f.id}`;
const triggerId = `${baseId}-trigger-${f.id}`;
return (
<div key={f.id}>
<button
id={triggerId}
aria-expanded={openIds.has(f.id)}
aria-controls={panelId}
onClick={() => toggle(f.id)}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-semibold"
>
{f.q}
<motion.span animate={{ rotate: openIds.has(f.id) ? 180 : 0 }} transition={{ duration: 0.2 }}>
<ChevronDown size={14} className="text-neutral-400" />
</motion.span>
</button>
<AnimatePresence initial={false}>
{openIds.has(f.id) && (
<motion.div
id={panelId}
role="region"
aria-labelledby={triggerId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<p className="px-4 pb-3 text-sm text-neutral-500">{f.a}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
})
)}
</div>
</div>
);
}
About this pattern
Filtering a list of expandable items breaks in a specific way if you are not careful: the moment open and closed state is keyed by an item's position in the filtered array, typing a new search term reshuffles what sits at each position and the wrong answers pop open or closed. This avoids that by attaching each FAQ's original, stable index as an id before filtering ever happens, then tracking open state as a Set of those ids, so filtering changes what is visible, never what a given id refers to. The search itself is a straightforward case-insensitive substring match against both question and answer text, recomputed with useMemo so it is not re-filtering on every unrelated render. Each answer's expand and collapse animates height and opacity through AnimatePresence rather than snapping instantly. Reach for this over a plain static FAQ list once the list is long enough that scanning it becomes the actual bottleneck; under a dozen items, the search box is probably overhead rather than help.
Curator’s note
Open/closed state is keyed by each FAQ's stable original id, not its position in the filtered array — tracking by filtered position would make typing into the search box silently reassign which answer appears open.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Fuzzy Match Input
componentsSubsequence search that scores by run length and word starts, and highlights the hits.
Command Palette
patternsA Cmd+K style searchable command menu with a filtered live list.
Expanding Panel Row
patternsA row of panels where the hovered one grows and its neighbours compress.