Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
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

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/faq-accordion.json

Pulls 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.

demos/faq-accordion.tsx
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.

components/sections/faq-accordion.tsx
/**
 * 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.

Props for FaqAccordionProps
PropTypeDefault
items*RequiredFaqItem[]
titlestring
eyebrowstring
descriptionstring
defaultOpenQuestions open by default, by `value` (or question slug).string[][]
multipleAllow several answers open at once. Default `false`.booleanfalse
itemHeadingLevelHeadingLevel
hiddenUntilFoundKeep closed answers findable by browser find-in-page. Default `true`.booleantrue
align"start" | "center""start"
schemaOrgEmit FAQPage JSON-LD for the items with plain-text answers.booleanfalse