FAQ Section
A searchable accordion that filters with layout animation.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/faq-section.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/faq-section/faq-section.tsx"use client"; import { useId, useState, type ComponentProps, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Search, X } from "lucide-react"; import { BouncyAccordion } from "@/components/ui/bouncy-accordion/bouncy-accordion"; import { cn } from "@/lib/utils"; import { spring } from "@/lib/motion"; export interface FaqItem { question: string; /** Answer content; plain strings are also searched. */ answer: ReactNode; } export interface FaqSectionProps extends Omit<ComponentProps<"section">, "children" | "title"> { /** Questions and answers. */ items: FaqItem[]; /** Section title. @default "Frequently asked questions" */ title?: string; /** Supporting line under the title. */ description?: string; /** Search field placeholder. @default "Search questions…" */ searchPlaceholder?: string; } export function FaqSection({ items, title = "Frequently asked questions", description, searchPlaceholder = "Search questions…", className, ...props }: FaqSectionProps) { const reduceMotion = useReducedMotion() ?? false; const id = useId(); const [query, setQuery] = useState(""); const q = query.trim().toLowerCase(); const results = items.filter( (item) => !q || item.question.toLowerCase().includes(q) || (typeof item.answer === "string" && item.answer.toLowerCase().includes(q)), ); return ( <section className={cn("w-full max-w-2xl", className)} aria-labelledby={`${id}-title`} {...props}> <h2 id={`${id}-title`} className="text-2xl font-semibold tracking-tight text-foreground"> {title} </h2> {description ? <p className="mt-2 text-[15px] leading-7 text-muted-foreground">{description}</p> : null} <div className="relative mt-6"> <Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" /> <input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder={searchPlaceholder} aria-label="Search questions" aria-controls={`${id}-list`} className="h-11 w-full rounded-xl border border-border bg-background pr-10 pl-10 text-sm text-foreground shadow-xs outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/15 [&::-webkit-search-cancel-button]:hidden" /> {query ? ( <button type="button" onClick={() => setQuery("")} aria-label="Clear search" className="after:absolute after:-inset-1 absolute top-1/2 right-1.5 inline-flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > <X className="size-3.5" aria-hidden="true" /> </button> ) : null} </div> <p className="sr-only" role="status" aria-live="polite"> {q ? `${results.length} question${results.length === 1 ? "" : "s"} found` : ""} </p> <motion.div id={`${id}-list`} layout={reduceMotion ? false : "size"} transition={spring.smooth} className="mt-4"> <AnimatePresence mode="popLayout" initial={false}> {results.length ? ( <motion.div key={results.map((r) => r.question).join("|")} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, transition: { duration: 0.12 } }} transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }} > <BouncyAccordion headingLevel={3} items={results.map((item) => ({ id: item.question, title: item.question, content: item.answer }))} /> </motion.div> ) : ( <motion.p key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground" > No questions match “{query.trim()}”. </motion.p> )} </AnimatePresence> </motion.div> </section> ); }
Usage
import { FaqSection } from "@/components/ui/faq-section/faq-section";
<FaqSection items={[{ question: "Do I need a package?", answer: "No, you copy the code." }]} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | FaqItem[] | – | Questions and answers. |
title | string | "Frequently asked questions" | Section title. |
description | string | – | Supporting line under the title. |
searchPlaceholder | string | "Search questions…" | Search field placeholder. |
Also accepts every native <section> attribute, including ref and className.
- Search matches questions, plus answers that are plain strings. Reuses Bouncy Accordion, so it installs with it.
Accessibility
- The search field is labelled and controls the list; the number of matches is announced politely as you type.
- Questions use Bouncy Accordion, so each is a heading with a button (aria-expanded) and full keyboard support.
- Filtering animates the list's height with a layout animation; reduced motion swaps results with a plain fade.