Questions and answers beside a heading, with a search that filters as you type and a closing line for anything else.
The things people ask before they start.
Something else on your mind?
pnpm dlx shadcn@latest add @latheui/faq-sectionFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-reactAdd the Lathe items this component imports.
Copy the source into your project.
"use client"
import * as React from "react"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export type FaqItem = {
id: string
question: string
answer: React.ReactNode
/** Plain text of the answer, searched along with the question. Defaults to `answer` when it is a string. */
text?: string
}
export type FaqSectionProps = Omit<React.ComponentProps<"section">, "children"> & {
title?: string
description?: string
/** Questions to show. Defaults to built-in samples. */
items?: FaqItem[]
/** Show the search field. Worth it from about six questions. */
searchable?: boolean
/** Text and action of the closing line, for example a link to support. Pass `null` to leave it out. */
contact?: { text: string; label: string; onSelect: () => void } | null
/**
* Keep room for the longest answer, so opening any question never changes the section's
* height or moves what comes after it.
*/
reserveSpace?: boolean
}
// ---- sample data: replace with your own -------------------------------------
const sampleItems: FaqItem[] = [
{ id: "own", question: "Do I own the code?", answer: "Yes. The CLI copies the source into your repository, and you can change anything." },
{ id: "theme", question: "Will it match my theme?", answer: "Components read the standard shadcn color tokens, so they pick up the theme you already have." },
{ id: "motion", question: "What about reduced motion?", answer: "Movement and scaling are removed when the system asks for less. Fades and state changes stay." },
{ id: "update", question: "How do I update a component?", answer: "Run the add command again and review the diff, the same as any other change to your code." },
{ id: "license", question: "Can I use it in client work?", answer: "Yes, in any number of projects, commercial ones included." },
]
// -----------------------------------------------------------------------------
/** Questions and answers with a heading, a search that filters as you type, and a way to ask something else. */
function FaqSection({
title = "Questions",
description = "The things people ask before they start.",
items = sampleItems,
searchable = true,
contact = { text: "Something else on your mind?", label: "Contact us", onSelect: () => {} },
reserveSpace = false,
className,
...props
}: FaqSectionProps) {
const [query, setQuery] = React.useState("")
// The reserved height: the list with every question closed, plus the tallest answer. Answers
// are measured from a hidden copy, laid out at the list's width, and re-measured when it changes.
const list = React.useRef<HTMLDivElement>(null)
const probe = React.useRef<HTMLDivElement>(null)
const [reserve, setReserve] = React.useState<number>()
React.useLayoutEffect(() => {
const node = list.current
if (!reserveSpace || !node) return
let width = -1
const measure = () => {
if (node.clientWidth === width || !probe.current) return
width = node.clientWidth
const open = Array.from(node.querySelectorAll<HTMLElement>("[data-slot=accordion-content]")).reduce(
(sum, content) => sum + content.offsetHeight,
0
)
const tallest = Math.max(0, ...Array.from(probe.current.children, (answer) => (answer as HTMLElement).offsetHeight))
setReserve(Math.ceil(node.offsetHeight - open + tallest))
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(node)
return () => observer.disconnect()
}, [reserveSpace])
const titleId = React.useId()
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
// Every word must appear somewhere in the question or the answer.
const found = items.filter((item) => {
const haystack = `${item.question} ${item.text ?? (typeof item.answer === "string" ? item.answer : "")}`.toLowerCase()
return words.every((word) => haystack.includes(word))
})
return (
<section
data-slot="faq-section"
aria-labelledby={titleId}
className={cn("grid w-full gap-x-12 gap-y-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]", className)}
{...props}
>
<header className="flex flex-col gap-3">
<h2 id={titleId} className="text-2xl font-semibold tracking-tight">
{title}
</h2>
<p className="leading-7 text-pretty text-muted-foreground">{description}</p>
{searchable && (
<Input
type="search"
aria-label={`Search ${title.toLowerCase()}`}
placeholder="Search"
value={query}
onChange={(event) => setQuery(event.target.value)}
startSlot={<SearchIcon aria-hidden="true" />}
className="mt-2 max-w-xs"
/>
)}
</header>
<div className="relative" style={{ minHeight: reserve }}>
<div ref={list} className="flex flex-col gap-4">
{found.length > 0 ? (
<Accordion defaultValue={found[0] ? [found[0].id] : []}>
{found.map((item) => (
<AccordionItem key={item.id} value={item.id}>
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<p className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
Nothing matches “{query}”.
</p>
)}
<p role="status" className="sr-only">
{query ? `${found.length} of ${items.length} questions shown` : ""}
</p>
{contact && (
<p className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted-foreground">
{contact.text}
<Button variant="outline" size="sm" onClick={contact.onSelect}>
{contact.label}
</Button>
</p>
)}
</div>
{reserveSpace && (
<div ref={probe} aria-hidden="true" inert className="invisible absolute inset-x-0 top-0 h-0 overflow-hidden">
{items.map((item) => (
// The same box an open answer gets.
<div key={item.id} className="pb-4 text-sm leading-6 text-pretty">
{item.answer}
</div>
))}
</div>
)}
</div>
</section>
)
}
export { FaqSection }Depends on
Also accepts native section attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Questions" | Heading and accessible name of the section. |
| description | string | — | Line under the heading. |
| items | FaqItem[] | sample questions | Questions to show, in order. The first starts open. |
| searchable | boolean | true | Show the search field. Worth it from about six questions. |
| contact | { text: string; label: string; onSelect: () => void } | null | — | Text and action of the closing line. Pass null to leave it out. |
| reserveSpace | boolean | false | Keeps room for the longest answer, so opening a question never changes the section's height or moves the content after it. |
| Prop | Type | Default | Description |
|---|---|---|---|
| id* (required) | string | — | Unique id. |
| question* (required) | string | — | The question, shown on the trigger. |
| answer* (required) | ReactNode | — | The answer. Text, links or any markup. |
| text | string | — | Plain text of the answer for the search. Needed only when answer is not a string. |
Give text alongside a rich answer so the search can still find it.
<FaqSection
items={[
{
id: "returns",
question: "Can I send something back?",
text: "Yes, within 30 days. See the returns policy.",
answer: <>Yes, within 30 days. See the <a href="/returns">returns policy</a>.</>,
},
]}
/>