blocksection
FAQ accordion
Base UI Accordion with real buttons inside headings, find-in-page reachable answers, and optional FAQPage structured data.
Also ships a motion variant: FAQ accordion (motion). The static one is the default.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/faq-accordion.jsonPulls in @base-ui-components/react@1.0.0-rc.0, lucide-react@^1.47.0, container, heading, section, section-header, types, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
import { FaqAccordion } from "@registry/sections/faq-accordion"
import { FAQS } from "./content"
export function Demo() {
return (
<FaqAccordion
eyebrow="FAQ"
title="Questions, answered"
items={FAQS}
defaultOpen={["faq-is-facade-ui-free"]}
align="center"
schemaOrg
/>
)
}Source
What the CLI copies into your project, byte for byte.
/**
* FaqAccordion — questions and answers as a Base UI Accordion.
*
* a11y: Base UI supplies the `aria-expanded`, `aria-controls`, roving focus and
* Home/End handling. What this file is responsible for is the part a library
* cannot decide:
*
* - Each question is a real `<button>` inside a heading, at a level derived
* from the section's own. Screen-reader users navigate an FAQ by heading, and
* a list of `<div>`s makes that impossible.
* - `hiddenUntilFound` is on by default, so closed answers stay in the DOM with
* `hidden="until-found"`. Browser find-in-page can then reach a closed answer
* and open it — which is how people actually use a long FAQ.
* - The chevron is `aria-hidden`; expanded state is announced by
* `aria-expanded`, not by an icon's rotation.
*
* The open/close animation is plain CSS driven by Base UI's
* `--accordion-panel-height`, so the static variant animates without any
* JavaScript motion library, and the reduced-motion block in the token layer
* switches it off.
*
* Set `schemaOrg` to emit FAQPage structured data. It only includes answers
* given as plain strings, since markup cannot go in JSON-LD.
*
* Dependencies: @base-ui-components/react, lucide-react, react,
* @/lib/types, @/lib/utils, @/components/ui/container,
* @/components/ui/heading, @/components/ui/section, @/components/ui/section-header.
*/
import { Accordion } from "@base-ui-components/react/accordion"
import { ChevronDownIcon } from "lucide-react"
import type { ElementType, ReactNode } from "react"
import type { HeadingLevel, ListSlotProps, SectionBaseProps } from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Container } from "@/components/ui/container"
import { Heading } from "@/components/ui/heading"
import { Section } from "@/components/ui/section"
import { SectionHeader } from "@/components/ui/section-header"
export interface FaqItem {
question: string
/** Rich content is fine; only string answers reach the structured data. */
answer: ReactNode
/** Plain-text answer for `schemaOrg`, when `answer` contains markup. */
plainAnswer?: string
/** Stable value. Defaults to a slug of the question. */
value?: string
}
export interface FaqAccordionProps extends SectionBaseProps, ListSlotProps {
items: FaqItem[]
title?: string
eyebrow?: string
description?: string
/** Questions open by default, by `value` (or question slug). */
defaultOpen?: string[]
/** Allow several answers open at once. Default `false`. */
multiple?: boolean
itemHeadingLevel?: HeadingLevel
/** Keep closed answers findable by browser find-in-page. Default `true`. */
hiddenUntilFound?: boolean
align?: "start" | "center"
/** Emit FAQPage JSON-LD for the items with plain-text answers. */
schemaOrg?: boolean
}
const valueOf = (item: FaqItem): string => item.value ?? slugId(item.question, "faq")
export function FaqAccordion({
items,
title,
eyebrow,
description,
defaultOpen = [],
multiple = false,
itemHeadingLevel,
hiddenUntilFound = true,
align = "start",
schemaOrg = false,
listAs,
itemAs,
headingLevel = 2,
as,
spacing = "md",
className,
id,
}: FaqAccordionProps) {
const Wrapper = (listAs ?? "div") as ElementType
const Item = (itemAs ?? "div") as ElementType
const titleId = title ? (id ? `${id}-title` : slugId(title)) : undefined
const itemLevel = itemHeadingLevel ?? (Math.min(headingLevel + 1, 6) as HeadingLevel)
const structured = schemaOrg
? items
.map((item) => ({
question: item.question,
text:
item.plainAnswer ??
(typeof item.answer === "string" ? item.answer : undefined),
}))
.filter((entry): entry is { question: string; text: string } =>
Boolean(entry.text),
)
: []
return (
<Section
as={as ?? (title ? "section" : "div")}
spacing={spacing}
labelledBy={titleId}
id={id}
className={className}
>
<Container size="md" className="flex flex-col gap-12">
{title ? (
<SectionHeader
align={align}
eyebrow={eyebrow}
title={title}
description={description}
headingLevel={headingLevel}
titleId={titleId}
/>
) : null}
<Accordion.Root
multiple={multiple}
defaultValue={defaultOpen}
hiddenUntilFound={hiddenUntilFound}
render={<Wrapper />}
className="divide-border border-border flex flex-col divide-y border-y"
>
{items.map((item) => (
<Accordion.Item key={valueOf(item)} value={valueOf(item)} render={<Item />}>
<Accordion.Header render={<Heading level={itemLevel} />}>
<Accordion.Trigger
className={cn(
"text-foreground group flex w-full items-center justify-between gap-4",
"min-h-14 py-5 text-left text-base font-medium sm:text-lg",
"duration-facade-fast ease-facade-out hover:text-foreground/80 cursor-pointer transition-colors",
"focus-visible:ring-ring rounded-sm outline-none focus-visible:ring-2",
)}
>
<span className="text-pretty">{item.question}</span>
<ChevronDownIcon
aria-hidden
focusable="false"
className="text-muted-foreground duration-facade-base ease-facade-out size-5 shrink-0 transition-transform group-data-[panel-open]:rotate-180"
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel
className={cn(
"h-[var(--accordion-panel-height)] overflow-hidden",
"duration-facade-base ease-facade-out transition-[height]",
"data-[ending-style]:h-0 data-[starting-style]:h-0",
)}
>
<div className="text-muted-foreground text-pretty pb-6 text-base">
{item.answer}
</div>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion.Root>
{structured.length > 0 ? (
<script
type="application/ld+json"
// Values come from the page's own content, not from user input.
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: structured.map((entry) => ({
"@type": "Question",
name: entry.question,
acceptedAnswer: { "@type": "Answer", text: entry.text },
})),
}),
}}
/>
) : null}
</Container>
</Section>
)
}Props
FaqAccordionProps
Extends SectionBaseProps, ListSlotProps.
| Prop | Type | Default |
|---|---|---|
items*Required | FaqItem[] | — |
title | string | — |
eyebrow | string | — |
description | string | — |
defaultOpenQuestions open by default, by `value` (or question slug). | string[] | [] |
multipleAllow several answers open at once. Default `false`. | boolean | false |
itemHeadingLevel | HeadingLevel | — |
hiddenUntilFoundKeep closed answers findable by browser find-in-page. Default `true`. | boolean | true |
align | "start" | "center" | "start" |
schemaOrgEmit FAQPage JSON-LD for the items with plain-text answers. | boolean | false |