FAQ Search
A searchable FAQ: matches highlight in questions and answers, topics filter the list, and answers open in place.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/faq-search.json'use client'
import { Fragment, useState } from 'react'
import './faq-search.css'
export interface Faq { q: string; a: string; topic: string }
function hl(text: string, q: string) {
if (!q) return text
const s = q.toLowerCase(), out: React.ReactNode[] = []
let i = 0, j = text.toLowerCase().indexOf(s)
while (j >= 0) { out.push(<Fragment key={i}>{text.slice(i, j)}</Fragment>, <mark key={'m' + j}>{text.slice(j, j + q.length)}</mark>); i = j + q.length; j = text.toLowerCase().indexOf(s, i) }
out.push(<Fragment key="e">{text.slice(i)}</Fragment>)
return out
}
export interface FaqSearchProps {
items: Faq[]
contactHref?: string
topics?: boolean
autoOpen?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** FAQ with search, highlight and topic filters. */
export function FaqSearch({ items, contactHref = '#', topics = true, autoOpen = true, fill = 'dark', className }: FaqSearchProps) {
const [q, setQ] = useState('')
const [topic, setTopic] = useState<string | null>(null)
const [open, setOpen] = useState<number[]>([])
const [vote, setVote] = useState<Record<number, boolean>>({})
const s = q.trim().toLowerCase()
const shown = items.map((f, i) => ({ ...f, i })).filter((f) => (!topic || f.topic === topic) && (!s || f.q.toLowerCase().includes(s) || f.a.toLowerCase().includes(s)))
const isOpen = (i: number, inA: boolean) => open.includes(i) !== (autoOpen && !!s && inA)
const all = Array.from(new Set(items.map((f) => f.topic)))
return (
<div className={['sd-faq', className].filter(Boolean).join(' ')} data-fill={fill}>
<label className="sd-faq__s">
<svg viewBox="0 0 16 16" aria-hidden><circle cx="7" cy="7" r="4.8" /><path d="m14 14-3.5-3.5" /></svg>
<input type="search" aria-label="Search questions" placeholder="Search questions" value={q} onChange={(e) => { setQ(e.target.value); setOpen([]) }} />
</label>
{topics && (
<div className="sd-faq__chips" role="group" aria-label="Topics">
{all.map((t) => <button key={t} type="button" aria-pressed={topic === t} onClick={() => setTopic(topic === t ? null : t)}>{t}</button>)}
</div>
)}
<p className="sd-faq__meta" aria-live="polite">{s || topic ? `${shown.length} ${shown.length === 1 ? 'answer' : 'answers'}` : `${items.length} questions`}</p>
<div className="sd-faq__list">
{shown.map((f) => {
const inA = !!s && f.a.toLowerCase().includes(s), o = isOpen(f.i, inA)
return (
<div key={f.i} className="sd-faq__it">
<button type="button" className="sd-faq__q" aria-expanded={o} onClick={() => setOpen((x) => (x.includes(f.i) ? x.filter((y) => y !== f.i) : [...x, f.i]))}>
<span>{hl(f.q, q.trim())}</span><svg viewBox="0 0 14 14" aria-hidden><path d="M7 2v10M2 7h10" /></svg>
</button>
{o && (
<div className="sd-faq__a">
<p>{hl(f.a, q.trim())}</p>
<div className="sd-faq__help">{vote[f.i] === undefined ? 'Helpful?' : 'Thanks for the feedback'}
<button type="button" aria-pressed={vote[f.i] === true} onClick={() => setVote({ ...vote, [f.i]: true })}>Yes</button>
<button type="button" aria-pressed={vote[f.i] === false} onClick={() => setVote({ ...vote, [f.i]: false })}>No</button>
</div>
</div>
)}
</div>
)
})}
{!shown.length && <p className="sd-faq__none">No answers for “{q}”. <a href={contactHref}>Ask support</a></p>}
</div>
</div>
)
}.sd-faq { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-faq[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-faq :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-faq :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-faq .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-faq { width: min(100%, 340px); }
.sd-faq__s { display: flex; align-items: center; gap: 8px; height: 42px; padding: 0 12px; border-radius: 12px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-faq__s:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-faq__s svg { width: 16px; height: 16px; fill: none; stroke: var(--mute); stroke-width: 2; stroke-linecap: round; flex: none; }
.sd-faq__s input { flex: 1; min-width: 0; border: 0; background: none; color: var(--fg); font: inherit; outline: 0; }
.sd-faq__s input::placeholder { color: var(--mute); }
.sd-faq__chips { display: flex; gap: 6px; margin: 10px 0 4px; flex-wrap: wrap; }
.sd-faq__chips button { padding: 5px 11px; border-radius: 99px; font-size: 12.5px; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute) !important; }
.sd-faq__chips button[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-faq__meta { margin: 8px 2px; font-size: 12px; color: var(--mute); }
.sd-faq__list { max-height: 330px; overflow-y: auto; }
.sd-faq__it { border-bottom: 1px solid var(--line); }
.sd-faq__q { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 12px 2px; text-align: left; font-size: 14px; font-weight: 500; }
.sd-faq__q svg { width: 14px; height: 14px; flex: none; fill: none; stroke: var(--mute); stroke-width: 2; stroke-linecap: round; transition: transform 200ms; }
.sd-faq__q[aria-expanded='true'] svg { transform: rotate(45deg); }
.sd-faq__a { padding: 0 2px 12px; font-size: 13.5px; line-height: 1.55; color: color-mix(in srgb, var(--fg) 78%, transparent); animation: sd-faq-in 200ms ease-out; }
.sd-faq__a p { margin: 0 0 8px; }
.sd-faq mark { background: var(--strong); color: var(--on); border-radius: 3px; padding: 0 2px; }
.sd-faq__help { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--mute); }
.sd-faq__help button { padding: 3px 9px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; }
.sd-faq__help button[aria-pressed='true'] { background: var(--strong); color: var(--on); box-shadow: none; }
.sd-faq__none { padding: 30px 10px; text-align: center; font-size: 13.5px; color: var(--mute); }
.sd-faq__none a { color: var(--fg); }
@keyframes sd-faq-in { from { opacity: 0; transform: translateY(-3px); } }
@media (prefers-reduced-motion: reduce) { .sd-faq *, .sd-faq *::before, .sd-faq *::after { animation: none !important; transition: none !important; } }Overview
Typing filters questions and answers together and highlights every match. Matching answers open automatically so the hit is visible.
Topic chips narrow the list; a no-results state offers a contact link. Each answer has a “Was this helpful?” pair.
When to use
- Help centres, pricing pages, onboarding.
When not to use
- Long documentation: use full search with pages.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/faq-search.json$
npx shadcn@latest add https://sundaeui.dev/r/faq-search.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/faq-search.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/faq-search.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy faq-search.tsx and faq-search.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { FaqSearch } from '@/components/sundae/faq-search'
<FaqSearch />Props
| Prop | Type | Default |
|---|---|---|
items*Questions. | { q: string; a: string; topic: string }[] | — |
contactHrefLink for no results. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Questions are buttons with aria-expanded controlling their answers.
- The result count is announced; highlights use <mark>.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-faq; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is FAQ Search free?
- Yes. FAQ Search is free for personal and commercial projects, with full source on this page.
- How do I install FAQ Search?
- Run npx shadcn@latest add https://sundaeui.dev/r/faq-search.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does FAQ Search support keyboard and screen readers?
- Questions are buttons with aria-expanded controlling their answers. The result count is announced; highlights use <mark>.
- Does FAQ Search respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Fold Accordion
Components · UnderstatedKeyboard Shortcuts Sheet
Components · RefinedEmoji Picker
Components · RefinedIcon Picker
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined