No-JS FAQ Accordion
Exclusive accordion behaviour from named <details> elements — no state, no hooks, no JavaScript, full keyboard support for free.
A search that narrows the list across answers and keywords, announces the count, and turns an empty result into the contact route it should always have been.
components/faq-search.tsxnpx hoverlab add faq-search
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Help
Yes, immediately. Upgrades are prorated to the day; downgrades credit the unused remainder against your next invoice rather than refunding it.
SAML 2.0 and OIDC on Team and above, with SCIM provisioning on Enterprise. Okta, Entra ID, Google Workspace and JumpCloud are tested; anything standards-compliant should work.
SOC 2 Type II, audited annually, with the current report available under NDA. GDPR and UK GDPR as a processor, with a DPA you can sign from the billing page. HIPAA BAAs on Enterprise.
600 requests a minute per workspace on paid plans, 60 on trials. Limits are per workspace rather than per key, so adding keys does not add capacity.
Within 30 days of a first purchase, no questions asked, on any plan. After that we credit rather than refund, except where local law says otherwise.
In the region you pick at workspace creation: Virginia, Frankfurt or Sydney. It does not leave that region, including for backups.
Yes, at no charge, on any plan. Most migrations take an afternoon and our exports import without a schema rewrite.
The workspace becomes read-only rather than being deleted. Everything is still there for 30 days, and adding a card restores it instantly.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <FaqSearch> — a filter over the questions, and a way out when it finds nothing.
*
* <FaqCategorized> helps a reader who knows which topic their question is
* under. Plenty of readers do not — they know a word. They know "SSO", or
* "refund", or "rate limit", and they will type it into whatever box is
* nearest, which on most help pages is the browser's own find bar.
*
* Filtering rather than jumping is the difference from every other FAQ block
* here. Cmd-F highlights the first match and leaves the other thirty-nine
* questions on screen; this removes them, which is what turns forty
* questions into the two that matter.
*
* It searches answers as well as questions. Someone typing "SOC 2" is
* looking for a word that appears in an answer whose question is phrased
* "how do you handle compliance", and a title-only filter tells them the
* page has nothing — the single most common way this pattern fails.
*
* The empty state is the reason the block exists. A search that finds
* nothing is the clearest signal you will ever get that a visitor has an
* unanswered question and is about to leave, so it is the one moment worth
* spending a contact route on. An empty state that only says "no results" is
* a dead end at exactly the wrong time.
*
* Two accessibility details:
*
* - The result count is a `role="status"` live region, so a screen reader
* hears "3 of 12 questions" as the list narrows. Without it, typing
* silently changes the page for anyone not watching it.
* - The input is a real `<label>` and a `type="search"`, not a placeholder
* doing double duty. Placeholder-as-label disappears on first keystroke,
* which is when it is most needed.
*
* Uncontrolled `<details>` here rather than the exclusive `name` grouping
* <FaqAccordion> uses: with a filter, having one answer close another is
* disorienting, because the reader is usually comparing the two results the
* search left them with.
*/
import * as React from 'react'
import { Plus, Search } from 'lucide-react'
export interface SearchableQuestion {
question: string
answer: string
/** Extra words that should match but do not belong in the copy. */
keywords?: string[]
}
export interface FaqSearchProps {
eyebrow?: string
heading?: string
inputLabel?: string
placeholder?: string
questions?: SearchableQuestion[]
emptyHeading?: string
emptyBody?: string
contactLabel?: string
contactHref?: string
className?: string
}
const DEFAULT_QUESTIONS: SearchableQuestion[] = [
{
question: 'Can I change plans mid-cycle?',
answer:
'Yes, immediately. Upgrades are prorated to the day; downgrades credit the unused remainder against your next invoice rather than refunding it.',
keywords: ['upgrade', 'downgrade', 'proration'],
},
{
question: 'Do you support single sign-on?',
answer:
'SAML 2.0 and OIDC on Team and above, with SCIM provisioning on Enterprise. Okta, Entra ID, Google Workspace and JumpCloud are tested; anything standards-compliant should work.',
keywords: ['SSO', 'SAML', 'Okta', 'SCIM'],
},
{
question: 'How do you handle compliance?',
answer:
'SOC 2 Type II, audited annually, with the current report available under NDA. GDPR and UK GDPR as a processor, with a DPA you can sign from the billing page. HIPAA BAAs on Enterprise.',
keywords: ['SOC 2', 'GDPR', 'HIPAA', 'DPA'],
},
{
question: 'What is the API rate limit?',
answer:
'600 requests a minute per workspace on paid plans, 60 on trials. Limits are per workspace rather than per key, so adding keys does not add capacity.',
keywords: ['throttle', '429'],
},
{
question: 'Can we get a refund?',
answer:
'Within 30 days of a first purchase, no questions asked, on any plan. After that we credit rather than refund, except where local law says otherwise.',
keywords: ['money back', 'cancel'],
},
{
question: 'Where is our data stored?',
answer:
'In the region you pick at workspace creation: Virginia, Frankfurt or Sydney. It does not leave that region, including for backups.',
keywords: ['residency', 'EU', 'region'],
},
{
question: 'Do you help with migration?',
answer:
'Yes, at no charge, on any plan. Most migrations take an afternoon and our exports import without a schema rewrite.',
keywords: ['import', 'switch', 'onboarding'],
},
{
question: 'What happens when a trial ends?',
answer:
'The workspace becomes read-only rather than being deleted. Everything is still there for 30 days, and adding a card restores it instantly.',
keywords: ['expire', 'trial'],
},
]
export function FaqSearch({
eyebrow = 'Help',
heading = 'Search the questions',
inputLabel = 'Search frequently asked questions',
placeholder = 'Try “SSO”, “refund”, “rate limit”…',
questions = DEFAULT_QUESTIONS,
emptyHeading = 'Nothing here matches that',
emptyBody = 'Which probably means it is a question we should be answering. Send it over and you will get a real reply, usually the same day.',
contactLabel = 'Ask us directly',
contactHref = '#',
className = '',
}: FaqSearchProps) {
/*
Ids come from `useId`, not from a literal.
A literal is correct until the section appears twice on one page — and
then `aria-labelledby` resolves to whichever copy is first in the
document, so the second one is announced with the first one's heading.
It renders identically and reads wrongly, which is why nothing catches
it but an id check.
*/
const headingId = React.useId()
const inputId = React.useId()
const [query, setQuery] = React.useState('')
const results = React.useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle) return questions
return questions.filter((item) =>
[item.question, item.answer, ...(item.keywords ?? [])]
.join(' ')
.toLowerCase()
.includes(needle),
)
}, [query, questions])
const filtering = query.trim().length > 0
return (
<section
aria-labelledby={headingId}
className={`mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
>
<div className="text-center">
<p className="text-sm font-medium uppercase tracking-wider text-primary">{eyebrow}</p>
<h2
id={headingId}
className="mt-3 text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl"
>
{heading}
</h2>
</div>
<div className="mt-8">
<label htmlFor={inputId} className="sr-only">
{inputLabel}
</label>
<div className="relative">
<Search
aria-hidden
className="pointer-events-none absolute start-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
/>
<input
id={inputId}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
className="h-12 w-full rounded-xl border border-field bg-background ps-12 pe-4 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
{/* Announced as the list narrows, not just drawn. */}
<p role="status" className="mt-3 h-5 text-sm text-muted-foreground">
{filtering
? `${results.length} of ${questions.length} question${questions.length === 1 ? '' : 's'}`
: ''}
</p>
</div>
{results.length > 0 ? (
<div className="mt-4 divide-y divide-border/60 border-y border-border/60">
{results.map((item) => (
<details key={item.question} className="group py-1">
<summary className="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{item.question}
<Plus
aria-hidden
className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45"
/>
</summary>
<p className="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">
{item.answer}
</p>
</details>
))}
</div>
) : (
<div className="mt-4 rounded-2xl border border-dashed border-border p-10 text-center">
<p className="text-lg font-semibold text-foreground">{emptyHeading}</p>
<p className="mx-auto mt-2 max-w-md text-pretty leading-relaxed text-muted-foreground">
{emptyBody}
</p>
<a
href={contactHref}
className="mt-6 inline-flex h-10 items-center rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{contactLabel}
</a>
</div>
)}
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/faq-search.tsx and import it where you need the section:
import { FaqSearch } from '@/components/faq-search'7 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
eyebrow | string | 'Help' |
heading | string | 'Search the questions' |
inputLabel | string | 'Search frequently asked questions' |
placeholder | string | 'Try “SSO”, “refund”, “rate limit”…' |
questions | SearchableQuestion[] | DEFAULT_QUESTIONS |
emptyHeading | string | 'Nothing here matches that' |
emptyBody | string | — |
contactLabel | string | 'Ask us directly' |
contactHref | string | '#' |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Filterable FAQ with Fallback — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section aria-labelledby="_R_0_" class="mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="text-center">
<p class="text-sm font-medium uppercase tracking-wider text-primary">Help</p>
<h2 id="_R_0_" class="mt-3 text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">Search the questions</h2>
</div>
<div class="mt-8">
<label for="_R_0H1_" class="sr-only">Search frequently asked questions</label>
<div class="relative">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search pointer-events-none absolute start-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" aria-hidden="true">
<path d="m21 21-4.34-4.34"></path>
<circle cx="11" cy="11" r="8"></circle>
</svg>
<input id="_R_0H1_" type="search" placeholder="Try “SSO”, “refund”, “rate limit”…" class="h-12 w-full rounded-xl border border-field bg-background ps-12 pe-4 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" value="" />
</div>
<p role="status" class="mt-3 h-5 text-sm text-muted-foreground"></p>
</div>
<div class="mt-4 divide-y divide-border/60 border-y border-border/60">
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Can I change plans mid-cycle?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></summary>
<p class="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">Yes, immediately. Upgrades are prorated to the day; downgrades credit the unused remainder against your next invoice rather than refunding it.</p>
</details>
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Do you support single sign-on?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></summary>
<p class="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">SAML 2.0 and OIDC on Team and above, with SCIM provisioning on Enterprise. Okta, Entra ID, Google Workspace and JumpCloud are tested; anything standards-compliant should work.</p>
</details>
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">How do you handle compliance?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></summary>
<p class="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">SOC 2 Type II, audited annually, with the current report available under NDA. GDPR and UK GDPR as a processor, with a DPA you can sign from the billing page. HIPAA BAAs on Enterprise.</p>
</details>
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">What is the API rate limit?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></summary>
<p class="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">600 requests a minute per workspace on paid plans, 60 on trials. Limits are per workspace rather than per key, so adding keys does not add capacity.</p>
</details>
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Can we get a refund?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></summary>
<p class="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">Within 30 days of a first purchase, no questions asked, on any plan. After that we credit rather than refund, except where local law says otherwise.</p>
</details>
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Where is our data stored?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></summary>
<p class="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">In the region you pick at workspace creation: Virginia, Frankfurt or Sydney. It does not leave that region, including for backups.</p>
</details>
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Do you help with migration?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></summary>
<p class="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">Yes, at no charge, on any plan. Most migrations take an afternoon and our exports import without a schema rewrite.</p>
</details>
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-4 text-start font-medium text-foreground marker:content-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">What happens when a trial ends?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></summary>
<p class="max-w-prose text-pretty pb-5 pe-10 leading-relaxed text-muted-foreground">The workspace becomes read-only rather than being deleted. Everything is still there for 30 days, and adding a card restores it instantly.</p>
</details>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse FAQCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Exclusive accordion behaviour from named <details> elements — no state, no hooks, no JavaScript, full keyboard support for free.
Questions on the right against a sticky rail that keeps a route to a human in view — for the visitor whose objection is not on the list.
Every answer rendered and visible in a two-column description list — the FAQ shape for a marketing page, where collapsed text is text a search engine will not quote.
Anchor-linked topic pills over per-topic details groups with the first answer open — for the FAQ that outgrew a single list, working with JavaScript disabled.
An FAQ written from the objections that stop a sale rather than from the questions that are comfortable to answer.
The security questions a buyer's IT reviewer asks, answered with scope and dates rather than with the word "enterprise-grade".