# 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.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/faq-2).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/faq-2
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/faq-2-light-fba5d89cee2e3cb1.png
- Registry item: https://ui.ballmac.com/r/faq-2.json
- Categories: blocks, faq
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/faq-2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/accordion.json`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/search-field.json`

## Files

### `registry/ballmac/components/blocks/faq-2/faq-2.tsx`

```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 }
```
