# FAQ Section

> Answers before someone has to ask: a short list on one plate, a longer list beside a heading that stays in view, or a filter box with an empty state.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/faq-section).

- Page, with a live preview: https://shadcn.exchange/r/miseui/faq-section
- Screenshot: https://shadcn.exchange/shot/miseui/faq-section/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/faq-section.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/faq-section.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://miseui.junsu4621.workers.dev/r/accordion.json`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/empty.json`, `https://miseui.junsu4621.workers.dev/r/search-field.json`

## Files

### `registry/miseui/blocks/faq-section.tsx`

```tsx
import { cn } from "@/registry/miseui/lib/utils"
import type { FaqItem } from "./faq-section-filter"
import { DEFAULT_CONTACT, DEFAULT_COPY, DEFAULT_ITEMS, FaqContactLine, FaqHeading, FaqList, ROOT_CLASS, type FaqContact, type FaqSectionCopyProps } from "./faq-section-parts"
import { FaqSectionSearch } from "./faq-section-search"

type FaqSectionVariant = "accordion" | "split" | "search"

interface FaqSectionProps extends FaqSectionCopyProps {
  /** `accordion` a short list on one plate, `split` a longer list beside a heading that stays in view, `search` a filter box for a list too long to scan. Anything else is `accordion`. */
  variant?: FaqSectionVariant
}

function FaqSectionAccordion({
  headline = DEFAULT_COPY.headline,
  headlineQuiet = DEFAULT_COPY.headlineQuiet,
  items = DEFAULT_ITEMS,
  contact = DEFAULT_CONTACT,
  className,
  ...props
}: FaqSectionCopyProps) {
  return (
    <section data-slot="faq-section" data-variant="accordion" className={cn(ROOT_CLASS, "bg-muted dark:bg-background", className)} {...props}>
      <div className="mx-auto flex w-full max-w-2xl flex-col gap-8 px-4 py-12 @2xl:py-16">
        <FaqHeading headline={headline} quiet={headlineQuiet} className="@2xl:text-center" />
        <FaqList items={items} variant="card" defaultOpen={items[0]?.id} className="rounded-2xl bg-background dark:bg-card" triggerClassName="min-h-12 @xl:px-6" panelClassName="@xl:px-6" />
        <FaqContactLine contact={contact} className="@2xl:text-center" />
      </div>
    </section>
  )
}

function FaqSectionSplit({
  headline = DEFAULT_COPY.headline,
  headlineQuiet = DEFAULT_COPY.headlineQuiet,
  items = DEFAULT_ITEMS,
  contact = DEFAULT_CONTACT,
  className,
  ...props
}: FaqSectionCopyProps) {
  return (
    <section data-slot="faq-section" data-variant="split" className={cn(ROOT_CLASS, "bg-background", className)} {...props}>
      <div className="mx-auto grid w-full max-w-6xl grid-cols-[minmax(0,1fr)] items-start gap-8 px-4 py-12 @2xl:px-8 @2xl:py-16 @4xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @4xl:gap-16">
        {/* Stays in view while a long list scrolls past, so the way to support is never far. */}
        <div data-slot="faq-section-aside" className="flex flex-col gap-6 @4xl:sticky @4xl:top-8">
          <FaqHeading headline={headline} quiet={headlineQuiet} className="text-3xl @2xl:text-3xl" />
          <FaqContactLine contact={contact} />
        </div>
        <FaqList items={items} variant="plain" defaultOpen={items[0]?.id} className="min-w-0 border-t" triggerClassName="min-h-12 py-4" />
      </div>
    </section>
  )
}

function FaqSection({ variant, ...props }: FaqSectionProps) {
  switch (variant) {
    case "split":
      return <FaqSectionSplit {...props} />
    case "search":
      return <FaqSectionSearch {...props} />
    default:
      return <FaqSectionAccordion {...props} />
  }
}

export { FaqSection, type FaqContact, type FaqItem, type FaqSectionProps, type FaqSectionVariant }
```

### `registry/miseui/blocks/faq-section-parts.tsx`

```tsx
import type * as React from "react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, type AccordionVariant } from "@/registry/miseui/ui/accordion"
import { cn } from "@/registry/miseui/lib/utils"
import type { FaqItem } from "./faq-section-filter"

interface FaqContact {
  /** Comes before the link, as a question. */
  prompt: string
  label: string
  href: string
}

interface FaqSectionCopyProps extends React.ComponentProps<"section"> {
  headline?: string
  /** Continues the headline in the same size, one tone quieter. */
  headlineQuiet?: string
  items?: readonly FaqItem[]
  /** Where to go when the answer is not here. Null removes the line. */
  contact?: FaqContact | null
}

const DEFAULT_COPY = {
  headline: "Questions, answered.",
  headlineQuiet: "The ones people ask before they invite it to a call.",
} as const

const DEFAULT_CONTACT: FaqContact = { prompt: "Did not find it?", label: "Write to support", href: "#support" }

const DEFAULT_ITEMS: readonly FaqItem[] = [
  { id: "join", question: "Does it join calls on its own?", answer: "Only when you invite it. It stays out of your calendar until you turn that on, and you can turn it on for one calendar at a time." },
  { id: "access", question: "Who can read a summary?", answer: "Everyone who was on the call, and anyone they share it with. Nobody else in your workspace sees it by default." },
  { id: "delete", question: "Can I delete a recording?", answer: "Yes, one at a time or all at once, from the call or from settings. The summary stays unless you delete that too.", keywords: "remove erase privacy" },
  { id: "trial", question: "What happens when the trial ends?", answer: "Nothing is charged until you pick a plan. Your summaries stay readable, and new calls stop being recorded.", keywords: "free billing payment" },
  { id: "export", question: "Can I take my data with me?", answer: "Export every summary as Markdown or PDF from settings, at any time and on any plan.", keywords: "download backup" },
  { id: "languages", question: "Which languages does it understand?", answer: "It follows the language of the call and writes the summary in the same one. You can ask for a translation afterwards.", keywords: "translate korean" },
  { id: "cancel", question: "How do I cancel?", answer: "From billing settings, with no call and no form. You keep access until the end of the period you paid for.", keywords: "refund stop subscription" },
]

/** Not centred in a tall frame like the other sections: this one changes height as rows open and results filter, and the row or the box under the pointer would move with it. */
const ROOT_CLASS = "@container flex w-full flex-col justify-start"

/** The bigger press area on touch comes from a pseudo element, because this link sits inside a sentence. */
const CONTACT_LINK_CLASS =
  "relative rounded-sm font-medium text-foreground underline underline-offset-4 outline-none hover:text-foreground/70 focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:after:absolute pointer-coarse:after:-inset-x-2 pointer-coarse:after:-inset-y-2.5"

function FaqHeading({ headline, quiet, className }: { headline: string; quiet: string; className?: string }) {
  return (
    <h2 className={cn("text-title text-3xl text-balance @2xl:text-4xl", className)}>
      {headline} <span className="text-muted-foreground">{quiet}</span>
    </h2>
  )
}

function FaqContactLine({ contact, className }: { contact: FaqContact | null; className?: string }) {
  if (!contact) return null
  return (
    <p className={cn("text-sm leading-5 text-muted-foreground", className)}>
      {contact.prompt}{" "}
      <a href={contact.href} className={CONTACT_LINK_CLASS}>
        {contact.label}
      </a>
    </p>
  )
}

interface FaqListProps {
  items: readonly FaqItem[]
  variant: AccordionVariant
  /** The question open at first. Showing one answer teaches the row is pressable and how long answers are. */
  defaultOpen?: string
  className?: string
  itemClassName?: string
  triggerClassName?: string
  panelClassName?: string
}

function FaqList({ items, variant, defaultOpen, className, itemClassName, triggerClassName, panelClassName }: FaqListProps) {
  if (items.length === 0) return null
  return (
    <Accordion variant={variant} defaultValue={defaultOpen} className={className}>
      {items.map((item) => (
        <AccordionItem key={item.id} value={item.id} className={itemClassName}>
          <AccordionTrigger className={cn("min-w-0 text-[15px] leading-snug", triggerClassName)}>{item.question}</AccordionTrigger>
          <AccordionContent className={cn("text-[15px]/relaxed text-pretty", panelClassName)}>{item.answer}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

export { DEFAULT_CONTACT, DEFAULT_COPY, DEFAULT_ITEMS, FaqContactLine, FaqHeading, FaqList, ROOT_CLASS, type FaqContact, type FaqSectionCopyProps }
```

### `registry/miseui/blocks/faq-section-search.tsx`

```tsx
import { useRef, useState } from "react"
import { SearchX } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Empty } from "@/registry/miseui/ui/empty"
import { SearchField } from "@/registry/miseui/ui/search-field"
import { faqCountLabel, filterFaq } from "./faq-section-filter"
import { DEFAULT_CONTACT, DEFAULT_COPY, DEFAULT_ITEMS, FaqContactLine, FaqHeading, FaqList, ROOT_CLASS, type FaqContact, type FaqSectionCopyProps } from "./faq-section-parts"

function NoMatches({ query, contact, onClear }: { query: string; contact: FaqContact | null; onClear: () => void }) {
  return (
    <Empty
      variant="dashed"
      icon={<SearchX />}
      title={`No answer for “${query.trim()}”`}
      description="Try a shorter word, or ask us directly."
      className="[overflow-wrap:anywhere]"
      actions={
        <>
          <Button variant="outline" onClick={onClear} className="pointer-coarse:h-10">
            Clear search
          </Button>
          {contact ? (
            <Button asChild variant="ghost" className="pointer-coarse:h-10">
              <a href={contact.href}>{contact.label}</a>
            </Button>
          ) : null}
        </>
      }
    />
  )
}

function FaqSectionSearch({
  headline = DEFAULT_COPY.headline,
  headlineQuiet = DEFAULT_COPY.headlineQuiet,
  items = DEFAULT_ITEMS,
  contact = DEFAULT_CONTACT,
  className,
  ...props
}: FaqSectionCopyProps) {
  const [query, setQuery] = useState("")
  const fieldRef = useRef<HTMLInputElement>(null)
  const matches = filterFaq(items, query)
  const clear = () => {
    setQuery("")
    // The pressed button goes away with the empty state, and focus would fall back to the top of the page with it.
    fieldRef.current?.focus()
  }
  return (
    <section data-slot="faq-section" data-variant="search" className={cn(ROOT_CLASS, "bg-muted dark:bg-background", className)} {...props}>
      <div className="mx-auto flex w-full max-w-2xl flex-col gap-8 px-4 py-12 @2xl:py-16">
        <FaqHeading headline={headline} quiet={headlineQuiet} />
        {items.length > 0 ? (
          <div className="flex flex-col gap-4">
            <SearchField ref={fieldRef} variant="large" placeholder="Search questions" value={query} onValueChange={setQuery} />
            {/* Stays mounted through every result count, so a screen reader hears each change. */}
            <p role="status" className="text-[13px] leading-5 text-muted-foreground tabular-nums">
              {faqCountLabel(matches.length, items.length)}
            </p>
            {matches.length > 0 ? (
              <>
                <FaqList
                  items={matches}
                  variant="separated"
                  className="min-w-0"
                  itemClassName="rounded-2xl bg-background dark:bg-card"
                  triggerClassName="min-h-12 rounded-2xl"
                />
                <FaqContactLine contact={contact} className="mt-2" />
              </>
            ) : (
              <NoMatches query={query} contact={contact} onClear={clear} />
            )}
          </div>
        ) : null}
      </div>
    </section>
  )
}

export { FaqSectionSearch }
```

### `registry/miseui/blocks/faq-section-filter.ts`

```ts
import type * as React from "react"

interface FaqItem {
  id: string
  question: string
  answer: React.ReactNode
  /** Extra words to match on `search`, such as "refund" on a question about cancelling. Needed when `answer` is not plain text. */
  keywords?: string
}

function searchableText(item: FaqItem) {
  const answer = typeof item.answer === "string" ? item.answer : ""
  return `${item.question} ${answer} ${item.keywords ?? ""}`.toLocaleLowerCase()
}

/** Every word has to appear somewhere, in any order: people type "cancel refund", not the wording of the question. */
function filterFaq(items: readonly FaqItem[], query: string): readonly FaqItem[] {
  const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
  if (words.length === 0) return items
  return items.filter((item) => {
    const text = searchableText(item)
    return words.every((word) => text.includes(word))
  })
}

function faqCountLabel(shown: number, total: number) {
  const noun = total === 1 ? "question" : "questions"
  return shown === total ? `${total} ${noun}` : `${shown} of ${total} ${noun}`
}

export { faqCountLabel, filterFaq, type FaqItem }
```
