Search FAQ Accordion
Original · freeAn FAQ accordion with a live search filter box.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/accordion-search-faq.json"use client";
import { useId, useMemo, useRef, useState } from "react";
type Faq = {
id: string;
category: string;
question: string;
answer: string;
keywords: string;
};
const FAQS: Faq[] = [
{
id: "billing-refund",
category: "Billing",
question: "What is your refund policy after I upgrade?",
answer:
"Any paid plan can be cancelled within 14 days for a full refund, no questions asked. After that window, cancelling stops the next renewal but the current period stays active until it ends. Refunds land back on the original card in 5–7 business days.",
keywords: "refund cancel money back guarantee cancellation",
},
{
id: "billing-proration",
category: "Billing",
question: "Will I be charged a prorated amount when I change plans mid-cycle?",
answer:
"Yes. Upgrades apply immediately and we charge only the difference for the days remaining in your billing period. Downgrades take effect at the start of your next cycle, so you keep the higher tier until then.",
keywords: "prorate proration upgrade downgrade change plan mid cycle charge",
},
{
id: "account-invite",
category: "Account",
question: "How many teammates can I invite on the Team plan?",
answer:
"The Team plan includes 10 seats. Additional seats are billed per member each month and can be added or removed at any time from Settings → Members. Guests with view-only access never count against your seat limit.",
keywords: "seats invite teammate members team users add people",
},
{
id: "account-password",
category: "Account",
question: "I forgot my password — how do I get back in?",
answer:
"Use the “Forgot password” link on the sign-in screen. We email a reset link that stays valid for 30 minutes. If it never arrives, check spam and confirm you are using the same address you signed up with, then request a fresh link.",
keywords: "password reset forgot login sign in locked out access",
},
{
id: "security-2fa",
category: "Security",
question: "Do you support two-factor authentication and SSO?",
answer:
"Every plan supports app-based two-factor authentication via any TOTP authenticator. SAML single sign-on with Okta, Google Workspace, and Azure AD is available on the Business plan, along with enforced 2FA across your whole organization.",
keywords: "2fa two factor authentication sso saml okta security login mfa",
},
{
id: "security-data",
category: "Security",
question: "Where is my data stored and is it encrypted?",
answer:
"Data is stored in ISO 27001-certified data centers in the EU and US, and you choose the region on setup. Everything is encrypted in transit with TLS 1.3 and at rest with AES-256. We never sell your data or use it to train third-party models.",
keywords: "data storage encryption region gdpr privacy aes tls compliance",
},
{
id: "api-limits",
category: "API",
question: "What are the rate limits on the public API?",
answer:
"The free tier allows 60 requests per minute and paid tiers scale to 1,000 per minute. Each response includes X-RateLimit headers so you can back off gracefully. Need more? Enterprise customers get dedicated limits negotiated per contract.",
keywords: "api rate limit requests throttle headers developer integration",
},
{
id: "api-webhooks",
category: "API",
question: "Can I receive webhooks when records change?",
answer:
"Yes. Configure webhook endpoints under Developer → Webhooks and subscribe to create, update, and delete events. We sign every payload with an HMAC secret and retry failed deliveries with exponential backoff for up to 24 hours.",
keywords: "webhook events notifications payload hmac retry developer api",
},
];
function highlight(text: string, query: string) {
const q = query.trim();
if (!q) return text;
const safe = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const parts = text.split(new RegExp(`(${safe})`, "ig"));
return parts.map((part, i) =>
part.toLowerCase() === q.toLowerCase() ? (
<mark
key={i}
className="rounded-[0.2em] bg-amber-200 px-0.5 text-inherit dark:bg-amber-400/30 dark:text-amber-100"
>
{part}
</mark>
) : (
part
)
);
}
export default function AccordionSearchFaq() {
const [query, setQuery] = useState("");
const [open, setOpen] = useState<string | null>(null);
const baseId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return FAQS;
return FAQS.filter((f) =>
(f.question + " " + f.answer + " " + f.keywords + " " + f.category)
.toLowerCase()
.includes(q)
);
}, [query]);
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 sm:px-6 lg:py-28 dark:bg-slate-950">
<style>{`
@keyframes asfaq_reveal {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.asfaq-panel[data-open="true"] > div { animation: asfaq_reveal 260ms ease; }
@media (prefers-reduced-motion: reduce) {
.asfaq-panel[data-open="true"] > div { animation: none; }
.asfaq-chevron { transition: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/20 blur-3xl dark:bg-indigo-600/10"
/>
<div className="relative mx-auto max-w-2xl">
<div className="mb-8 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<svg viewBox="0 0 24 24" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
<path
d="M12 3a9 9 0 100 18 9 9 0 000-18zm0 13v.01M12 8a2 2 0 012 2c0 1-1 1.5-1.6 2-.4.3-.4.6-.4 1"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Help Center
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Frequently asked questions
</h2>
<p className="mt-3 text-base text-slate-600 dark:text-slate-400">
Search across billing, security, and the API. Can’t find it? We’re one message away.
</p>
</div>
<div className="relative mb-6">
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400 dark:text-slate-500"
>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
<path d="M20 20l-3.2-3.2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
type="text"
role="searchbox"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search questions…"
aria-label="Search frequently asked questions"
aria-controls={`${baseId}-list`}
className="w-full rounded-xl border border-slate-300 bg-white py-3.5 pl-11 pr-24 text-sm text-slate-900 shadow-sm outline-none transition focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/15 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
/>
{query ? (
<button
type="button"
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
Clear
</button>
) : null}
</div>
<p className="mb-3 px-1 text-xs font-medium text-slate-500 dark:text-slate-500" aria-live="polite">
{filtered.length === FAQS.length
? `${FAQS.length} questions`
: `${filtered.length} of ${FAQS.length} match “${query.trim()}”`}
</p>
<ul id={`${baseId}-list`} className="space-y-3">
{filtered.map((f) => {
const isOpen = open === f.id;
const btnId = `${baseId}-btn-${f.id}`;
const panelId = `${baseId}-panel-${f.id}`;
return (
<li
key={f.id}
className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700"
>
<h3>
<button
id={btnId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpen(isOpen ? null : f.id)}
className="flex w-full items-center gap-3 px-5 py-4 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400"
>
<span className="mt-0.5 shrink-0 rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{f.category}
</span>
<span className="flex-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
{highlight(f.question, query)}
</span>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="asfaq-chevron h-5 w-5 shrink-0 text-slate-400 transition-transform duration-300 dark:text-slate-500"
style={{ transform: isOpen ? "rotate(180deg)" : "rotate(0deg)" }}
>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={btnId}
hidden={!isOpen}
data-open={isOpen}
className="asfaq-panel"
>
<div className="px-5 pb-5 pl-[4.75rem] text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{highlight(f.answer, query)}
</div>
</div>
</li>
);
})}
</ul>
{filtered.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-white px-6 py-12 text-center dark:border-slate-700 dark:bg-slate-900">
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="mx-auto h-8 w-8 text-slate-300 dark:text-slate-600"
>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.6" />
<path d="M20 20l-3.2-3.2M9 11h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
<p className="mt-3 text-sm font-medium text-slate-700 dark:text-slate-300">
No results for “{query.trim()}”
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-500">
Try a different keyword, or{" "}
<button
type="button"
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
className="font-semibold text-indigo-600 underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-400"
>
clear the search
</button>
.
</p>
</div>
) : null}
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Simple Accordion
OriginalA clean single-column accordion, one panel open at a time, chevron rotates.

Multi Accordion
OriginalAn accordion where multiple panels can be open at once.

Plus Minus Accordion
OriginalAccordion with a plus/minus icon that toggles per item.

Bordered Accordion
OriginalBordered card-style accordion panels with rounded corners.

Filled Accordion
OriginalFilled, tinted accordion panels; the active one is highlighted.

FAQ Two Col Accordion
OriginalA two-column FAQ accordion layout.

Numbered Accordion
OriginalA numbered-step accordion (01, 02, 03...).
Icon Left Accordion
OriginalAccordion with a leading icon per item and a right chevron.

Gradient Active Accordion
OriginalAccordion where the open panel gets a soft gradient background.

Nested Accordion
OriginalAn accordion with nested sub-accordions.

Flush Accordion
OriginalA borderless, flush accordion separated by hairlines.

Pill Accordion
OriginalAccordion with rounded pill-shaped headers.

