FAQ 2: searchable with topic filters
Frequently asked questions with live search that highlights matches, topic filters with counts, a friendly empty state and a support prompt. Topics scroll sideways on phones.
// Ballmac UI: FAQ 2. https://ui.ballmac.com/blocks/faq-2
"use client"
import * as React from "react"
import { LifeBuoy, SearchX } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ballmac/accordion"
import { buttonVariants } from "@/components/ballmac/button"
import { SearchField } from "@/components/ballmac/search-field"
import { cn } from "@/lib/utils"
type Faq2Question = {
/** The question. */
question: string
/** The answer, as plain text. */
answer: string
}
type Faq2Category = {
/** Category name. */
name: string
/** Questions in this category. */
questions: Faq2Question[]
}
type Faq2Props = Omit<React.ComponentProps<"section">, "title"> & {
/** Section heading. */
title?: string
/** One sentence under the heading. */
description?: string
/** Question groups. An "All" filter is added in front. */
categories?: Faq2Category[]
/** Contact shown when nothing matches, and under the list. */
support?: { label: string; href: string; text?: string } | null
/** Placeholder for the search field. */
searchPlaceholder?: string
}
const defaults: Faq2Category[] = [
{
name: "Billing",
questions: [
{ question: "Can I change plans at any time?", answer: "Yes. Upgrades take effect immediately and we prorate the difference. Downgrades start at the end of your billing period." },
{ question: "Do you offer refunds?", answer: "If you are not happy within the first 30 days of a paid plan, email us and we will refund you in full, no questions asked." },
{ question: "Which payment methods do you accept?", answer: "All major credit and debit cards, plus invoices and bank transfer on annual Scale plans." },
{ question: "Is VAT included in the price?", answer: "Prices exclude taxes. VAT or sales tax is calculated at checkout based on your billing address." },
],
},
{
name: "Security",
questions: [
{ question: "Is my data encrypted?", answer: "Everything is encrypted in transit with TLS 1.3 and at rest with AES-256. Backups use separate keys." },
{ question: "Do you support single sign-on?", answer: "SAML SSO and SCIM provisioning are available on Pro and Scale plans and work with Okta, Entra ID and Google Workspace." },
{ question: "Where is my data stored?", answer: "You choose a region when you create a workspace: United States, European Union or United Kingdom. Data stays in that region." },
],
},
{
name: "Product",
questions: [
{ question: "How do I import data from another tool?", answer: "Use the import wizard in Settings to upload a CSV or connect your old account. Most teams finish in under an hour." },
{ question: "Is there an API?", answer: "Yes. Every action in the app is available through a REST API and webhooks, with official SDKs for TypeScript, Python and Go." },
{ question: "Can I invite people outside my company?", answer: "Guests can view and comment on shared items without taking a paid seat. Pro includes 50 guests and Scale is unlimited." },
],
},
]
function highlight(text: string, query: string) {
const q = query.trim()
if (!q) return text
const parts = text.split(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"))
return parts.map((part, i) =>
part.toLowerCase() === q.toLowerCase() ? (
<mark key={i} className="bg-chart-3/30 text-foreground rounded-sm px-0.5">
{part}
</mark>
) : (
part
)
)
}
function Faq2({
title = "Questions, answered.",
description = "Search the list or pick a topic. Still stuck? We reply within a day.",
categories = defaults,
support = { label: "Contact support", href: "#", text: "Can’t find what you need?" },
searchPlaceholder = "Search questions",
className,
...props
}: Faq2Props) {
const [category, setCategory] = React.useState("All")
const [query, setQuery] = React.useState("")
const q = query.trim().toLowerCase()
const visible = categories
.filter((c) => category === "All" || c.name === category)
.flatMap((c) => c.questions.map((item) => ({ ...item, category: c.name })))
.filter((item) => !q || item.question.toLowerCase().includes(q) || item.answer.toLowerCase().includes(q))
const names = ["All", ...categories.map((c) => c.name)]
const total = categories.reduce((n, c) => n + c.questions.length, 0)
return (
<section data-slot="faq-2" className={cn("mx-auto max-w-6xl px-4 py-20 sm:px-6 md:py-28", className)} {...props}>
<div className="grid gap-10 lg:grid-cols-[20rem_1fr] lg:gap-16">
<div className="min-w-0">
<h2 className="text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl">{title}</h2>
<p className="text-muted-foreground mt-4 text-pretty">{description}</p>
<SearchField className="mt-6" label="Search questions" placeholder={searchPlaceholder} value={query} onValueChange={setQuery} />
<div role="group" aria-label="Topics" className="mt-5 flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:none] lg:flex-col lg:overflow-visible">
{names.map((name) => {
const on = category === name
const n = name === "All" ? total : (categories.find((c) => c.name === name)?.questions.length ?? 0)
return (
<button
key={name}
type="button"
aria-pressed={on}
onClick={() => setCategory(name)}
className={cn(
"focus-visible:ring-ring/50 flex h-9 shrink-0 items-center justify-between gap-3 rounded-full border px-4 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] lg:rounded-xl",
on ? "bg-foreground text-background border-transparent" : "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
{name}
<span className={cn("text-xs tabular-nums", on ? "text-background/70" : "text-muted-foreground")}>{n}</span>
</button>
)
})}
</div>
</div>
<div className="min-w-0">
<p role="status" className="sr-only">
{visible.length === 0 ? "No questions match your search." : `${visible.length} ${visible.length === 1 ? "question" : "questions"} shown.`}
</p>
{visible.length > 0 ? (
<Accordion type="single" collapsible className="divide-y rounded-2xl border px-5 sm:px-6">
{visible.map((item) => (
<AccordionItem key={item.question} value={item.question} className="border-b-0">
<AccordionTrigger className="py-5 text-base">
<span className="flex flex-col items-start gap-1 text-start">
{category === "All" && <span className="text-muted-foreground text-xs font-normal">{item.category}</span>}
{highlight(item.question, query)}
</span>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground text-base leading-relaxed text-pretty">{highlight(item.answer, query)}</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<div className="flex flex-col items-center rounded-2xl border border-dashed px-6 py-14 text-center">
<span className="bg-muted flex size-12 items-center justify-center rounded-full"><SearchX className="size-5" aria-hidden="true" /></span>
<p className="mt-4 font-semibold">No answers found</p>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">Nothing matches “{query}”. Try a shorter word, or ask us directly.</p>
<button type="button" onClick={() => { setQuery(""); setCategory("All") }} className={buttonVariants({ variant: "outline", size: "sm", shape: "pill", className: "mt-5" })}>
Reset filters
</button>
</div>
)}
{support && (
<div className="bg-muted/40 mt-6 flex flex-col items-start gap-3 rounded-2xl border p-5 sm:flex-row sm:items-center sm:justify-between">
<p className="flex items-center gap-2.5 text-sm">
<LifeBuoy className="text-muted-foreground size-4" aria-hidden="true" />
{support.text ?? "Still have questions?"}
</p>
<a href={support.href} className={buttonVariants({ variant: "outline", size: "sm", shape: "pill" })}>{support.label}</a>
</div>
)}
</div>
</div>
</section>
)
}
export { Faq2, type Faq2Props, type Faq2Category, type Faq2Question }Installation
$ pnpm dlx shadcn@latest add @ballmac/faq-2Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/accordion @ballmac/button @ballmac/search-fieldCopy the source into your project.
components/ballmac/blocks/faq-2/faq-2.tsx// Ballmac UI: FAQ 2. https://ui.ballmac.com/blocks/faq-2 "use client" import * as React from "react" import { LifeBuoy, SearchX } from "lucide-react" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ballmac/accordion" import { buttonVariants } from "@/components/ballmac/button" import { SearchField } from "@/components/ballmac/search-field" import { cn } from "@/lib/utils" type Faq2Question = { /** The question. */ question: string /** The answer, as plain text. */ answer: string } type Faq2Category = { /** Category name. */ name: string /** Questions in this category. */ questions: Faq2Question[] } type Faq2Props = Omit<React.ComponentProps<"section">, "title"> & { /** Section heading. */ title?: string /** One sentence under the heading. */ description?: string /** Question groups. An "All" filter is added in front. */ categories?: Faq2Category[] /** Contact shown when nothing matches, and under the list. */ support?: { label: string; href: string; text?: string } | null /** Placeholder for the search field. */ searchPlaceholder?: string } const defaults: Faq2Category[] = [ { name: "Billing", questions: [ { question: "Can I change plans at any time?", answer: "Yes. Upgrades take effect immediately and we prorate the difference. Downgrades start at the end of your billing period." }, { question: "Do you offer refunds?", answer: "If you are not happy within the first 30 days of a paid plan, email us and we will refund you in full, no questions asked." }, { question: "Which payment methods do you accept?", answer: "All major credit and debit cards, plus invoices and bank transfer on annual Scale plans." }, { question: "Is VAT included in the price?", answer: "Prices exclude taxes. VAT or sales tax is calculated at checkout based on your billing address." }, ], }, { name: "Security", questions: [ { question: "Is my data encrypted?", answer: "Everything is encrypted in transit with TLS 1.3 and at rest with AES-256. Backups use separate keys." }, { question: "Do you support single sign-on?", answer: "SAML SSO and SCIM provisioning are available on Pro and Scale plans and work with Okta, Entra ID and Google Workspace." }, { question: "Where is my data stored?", answer: "You choose a region when you create a workspace: United States, European Union or United Kingdom. Data stays in that region." }, ], }, { name: "Product", questions: [ { question: "How do I import data from another tool?", answer: "Use the import wizard in Settings to upload a CSV or connect your old account. Most teams finish in under an hour." }, { question: "Is there an API?", answer: "Yes. Every action in the app is available through a REST API and webhooks, with official SDKs for TypeScript, Python and Go." }, { question: "Can I invite people outside my company?", answer: "Guests can view and comment on shared items without taking a paid seat. Pro includes 50 guests and Scale is unlimited." }, ], }, ] function highlight(text: string, query: string) { const q = query.trim() if (!q) return text const parts = text.split(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi")) return parts.map((part, i) => part.toLowerCase() === q.toLowerCase() ? ( <mark key={i} className="bg-chart-3/30 text-foreground rounded-sm px-0.5"> {part} </mark> ) : ( part ) ) } function Faq2({ title = "Questions, answered.", description = "Search the list or pick a topic. Still stuck? We reply within a day.", categories = defaults, support = { label: "Contact support", href: "#", text: "Can’t find what you need?" }, searchPlaceholder = "Search questions", className, ...props }: Faq2Props) { const [category, setCategory] = React.useState("All") const [query, setQuery] = React.useState("") const q = query.trim().toLowerCase() const visible = categories .filter((c) => category === "All" || c.name === category) .flatMap((c) => c.questions.map((item) => ({ ...item, category: c.name }))) .filter((item) => !q || item.question.toLowerCase().includes(q) || item.answer.toLowerCase().includes(q)) const names = ["All", ...categories.map((c) => c.name)] const total = categories.reduce((n, c) => n + c.questions.length, 0) return ( <section data-slot="faq-2" className={cn("mx-auto max-w-6xl px-4 py-20 sm:px-6 md:py-28", className)} {...props}> <div className="grid gap-10 lg:grid-cols-[20rem_1fr] lg:gap-16"> <div className="min-w-0"> <h2 className="text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl">{title}</h2> <p className="text-muted-foreground mt-4 text-pretty">{description}</p> <SearchField className="mt-6" label="Search questions" placeholder={searchPlaceholder} value={query} onValueChange={setQuery} /> <div role="group" aria-label="Topics" className="mt-5 flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:none] lg:flex-col lg:overflow-visible"> {names.map((name) => { const on = category === name const n = name === "All" ? total : (categories.find((c) => c.name === name)?.questions.length ?? 0) return ( <button key={name} type="button" aria-pressed={on} onClick={() => setCategory(name)} className={cn( "focus-visible:ring-ring/50 flex h-9 shrink-0 items-center justify-between gap-3 rounded-full border px-4 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] lg:rounded-xl", on ? "bg-foreground text-background border-transparent" : "text-muted-foreground hover:text-foreground hover:bg-accent" )} > {name} <span className={cn("text-xs tabular-nums", on ? "text-background/70" : "text-muted-foreground")}>{n}</span> </button> ) })} </div> </div> <div className="min-w-0"> <p role="status" className="sr-only"> {visible.length === 0 ? "No questions match your search." : `${visible.length} ${visible.length === 1 ? "question" : "questions"} shown.`} </p> {visible.length > 0 ? ( <Accordion type="single" collapsible className="divide-y rounded-2xl border px-5 sm:px-6"> {visible.map((item) => ( <AccordionItem key={item.question} value={item.question} className="border-b-0"> <AccordionTrigger className="py-5 text-base"> <span className="flex flex-col items-start gap-1 text-start"> {category === "All" && <span className="text-muted-foreground text-xs font-normal">{item.category}</span>} {highlight(item.question, query)} </span> </AccordionTrigger> <AccordionContent className="text-muted-foreground text-base leading-relaxed text-pretty">{highlight(item.answer, query)}</AccordionContent> </AccordionItem> ))} </Accordion> ) : ( <div className="flex flex-col items-center rounded-2xl border border-dashed px-6 py-14 text-center"> <span className="bg-muted flex size-12 items-center justify-center rounded-full"><SearchX className="size-5" aria-hidden="true" /></span> <p className="mt-4 font-semibold">No answers found</p> <p className="text-muted-foreground mt-1 max-w-sm text-sm">Nothing matches “{query}”. Try a shorter word, or ask us directly.</p> <button type="button" onClick={() => { setQuery(""); setCategory("All") }} className={buttonVariants({ variant: "outline", size: "sm", shape: "pill", className: "mt-5" })}> Reset filters </button> </div> )} {support && ( <div className="bg-muted/40 mt-6 flex flex-col items-start gap-3 rounded-2xl border p-5 sm:flex-row sm:items-center sm:justify-between"> <p className="flex items-center gap-2.5 text-sm"> <LifeBuoy className="text-muted-foreground size-4" aria-hidden="true" /> {support.text ?? "Still have questions?"} </p> <a href={support.href} className={buttonVariants({ variant: "outline", size: "sm", shape: "pill" })}>{support.label}</a> </div> )} </div> </div> </section> ) } export { Faq2, type Faq2Props, type Faq2Category, type Faq2Question }Update the import paths to match your project setup.
Usage
import { Faq2 } from "@/components/ballmac/blocks/faq-2/faq-2"
export default function Faq2Demo() {
return <Faq2 />
}Dependencies
- npm
- lucide-react
Use with AI
A help-centre style FAQ. Pass categories=[{ name, questions: [{ question, answer }] }]; an All filter and search are built in. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI FAQ 2: searchable with topic filters (@ballmac/faq-2) to this project with the shadcn MCP, then use it where it fits.
Use it for
- More than eight questions across topics
- Pricing and product pages that attract many questions
Not for
- A handful of questions (use faq-1)
Registry JSON: https://ui.ballmac.com/r/faq-2.json
Free to use in personal and commercial projects.
More faq blocks
FAQ 1: accordion with heading
Frequently asked questions in an accessible accordion, with the heading and a help line in a side column on desktop.
Faq Pro 1: searchable FAQ with highlights
A search box that filters questions and highlights the match, topic chips and an accordion. A friendly empty state links to support.
Faq Pro 2: helpful votes and support card
An accordion where each answer asks Was this helpful, next to a sticky support card with chat and email.