FAQ
A run of questions, one open at a time, separated by rules.
Installation
Usage
import { Faq, FaqItem, FaqQuestion, FaqAnswer } from "@/components/ui/faq"Examples
Default
One answer is open at a time, and clicking the open question closes it. Items are divided by a rule rather than each sitting in its own box: a FAQ is one block with several parts, not several blocks. A stack of Collapsibles is the other shape, and it is the right one when several disclosures should be open together.
Point the shadcn CLI at the component’s registry URL. The source lands in your project as editable code, with no package to keep in sync.
Yes. Every component installs as source. Edit it, rename it, or delete the parts you don’t use.
Override the CSS variables in your own stylesheet. Changing one hue variable retones an entire eleven-step scale.
Rich answers
An answer holds paragraphs, lists, and links. The kit's inline-link rule is applied by the component, so an anchor is never styled at the call site; a control that is not an anchor opts in with data-inline-link and reads as part of the sentence.
Real content: several paragraphs, lists, and ordinary links that pick up the kit’s inline-link rule without being styled at the call site.
A control that is not an anchor opts in with data-inline-link, so a reads as part of the sentence instead of as a control dropped into it.
One answer is open at a time, so the reader is never asked to choose between two open panels. Clicking an open question closes it and leaves nothing open.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| valueFaq | string | null | - | The open item's value, for a controlled block. Omit to leave it uncontrolled. |
| defaultValueFaq | string | null | - | The item open on mount. Opening one keeps the block from reading as inert. |
| onValueChangeFaq | (value: string | null) => void | - | Called with the newly open item, or null when the open one is closed. |
| value*FaqItem | string | - | Identifies the item. Must be unique within the block. |
| levelFaqQuestion | 2 | 3 | 4 | 5 | 6 | 3 | Heading rank, so a page of these has a real outline. 3 sits under a section h2; use 2 when the FAQ is its own section under the h1. |
| disabledFaqItem | boolean | false | Disables the item; the question dims and stops responding. |
"use client"
import * as React from "react"
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { IconPlus as PlusIcon } from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
// Reveal timing. ONE duration in both directions, which is where this parts
// company with Collapsible.
//
// Collapsible closes faster than it opens (200/150) and is right to: a lone
// disclosure shutting has nothing else on screen, so the exit can lose the
// ceremony. This shipped with those numbers copied across, and an accordion
// does not have that shape. Closing here is almost never a standalone act:
// clicking one question collapses the open one WHILE the new one expands, and
// those are two halves of a single change. Run them at different speeds and
// the collapsing panel lands 50ms early, so the list lurches and then carries
// on settling. Paired elements share a duration and a curve.
//
// A FAQ answer is also a paragraph or three, well past the small content a
// Collapsible usually wraps, and longer travel wants longer duration, not
// shorter.
const REVEAL_MS = 200
// A strong ease-out, punchy start and smooth settle, mirroring the site's
// --ease-out-quart token. Inlined so the registry item stays self-contained.
const EASE_OUT = "cubic-bezier(0.165, 0.84, 0.44, 1)"
/**
* A run of questions, one open at a time, separated by rules.
*
* NOT a list of Collapsibles, and the difference is the whole reason this
* exists. `Collapsible` is ONE disclosure in its own bordered box: it ships a
* surface because a lone disclosure with no edge looks unfinished. Stack six of
* those and you get six competing boxes, each with its own ring, reading as six
* separate things when the content is one thing with six parts. A FAQ is a
* single block whose items are divided by a rule, and only one answer is open,
* because two open answers make the reader choose where to look.
*
* ONE OPEN AT A TIME is enforced here rather than left to the caller. Base UI's
* accordion holds an ARRAY of open values and allows several, so this component
* controls that value and keeps only the last entry. Clicking the open question
* still closes it: Base UI removes the value, the array empties, and nothing is
* open. A caller who wants several open at once wants a list of Collapsibles,
* not this.
*
* Uncontrolled by default. Pass `defaultValue` to open one on mount (a common
* marketing ask, so the block does not read as inert), or `value` +
* `onValueChange` to drive it from outside.
*/
// @use-when a run of questions with one open at a time.
function Faq({
value,
defaultValue,
onValueChange,
className,
...props
}: Omit<
React.ComponentProps<typeof AccordionPrimitive.Root>,
"value" | "defaultValue" | "onValueChange"
> & {
/** The open item's value. Omit for an uncontrolled block. */
value?: string | null
/** The item open on mount. */
defaultValue?: string | null
/** Fires with the newly open item, or `null` when the open one is closed. */
onValueChange?: (value: string | null) => void
}) {
const [uncontrolled, setUncontrolled] = React.useState<string | null>(
defaultValue ?? null
)
const isControlled = value !== undefined
const open = isControlled ? value : uncontrolled
// Base UI hands back every open value. Keeping the LAST one is what makes
// this single-open: opening a second question drops the first, and closing
// the open one leaves an empty array, so nothing is open.
const handleChange = (next: unknown[]) => {
const last = (next.length ? next[next.length - 1] : null) as string | null
if (!isControlled) setUncontrolled(last)
onValueChange?.(last)
}
return (
<AccordionPrimitive.Root
data-slot="faq"
value={open == null ? [] : [open]}
onValueChange={handleChange}
className={cn("w-full", className)}
{...props}
/>
)
}
/**
* One question and its answer.
*
* The rule is a bottom border dropped on the last item, NOT a top border added
* from the second onward. Both produce the same picture at three items; only
* this one survives a single item (no stray rule under a lone question) and a
* caller who reorders or conditionally renders items, because `last:` is
* evaluated by the browser against what actually rendered rather than by the
* author against what they expect to render.
*/
function FaqItem({
className,
...props
}: Omit<React.ComponentProps<typeof AccordionPrimitive.Item>, "value"> & {
/**
* Identifies the item, and REQUIRED here even though Base UI allows it to be
* omitted. Left off, Base UI falls back to the item's index, which makes the
* open item positional: insert a question above an open one and a different
* answer is open. `Faq` also reports this value back through
* `onValueChange`, and an index is not something a caller can act on.
*/
value: string
}) {
return (
<AccordionPrimitive.Item
data-slot="faq-item"
className={cn("border-b border-border last:border-b-0", className)}
{...props}
/>
)
}
/**
* The question, and the button that opens it.
*
* `level` sets the heading rank so a page of these has a real outline. It
* renders an `h3` by default, which is right under a section `h2`; a FAQ that
* is its own page section under an `h1` wants `level={2}`. The heading is
* always a real heading element with a button inside it, which is the shape
* screen readers announce as "collapsed, button, heading level 3".
*
* Muted at rest, full foreground when open, and it TRANSITIONS between them.
* The open question is the thing being read, so it earns the foreground; the
* closed ones are a menu of choices. Colour is not carrying this alone, since
* the icon state is redundant with it.
*
* The icon is a plus that rotates 45 degrees into a cross. A plus reads as
* "there is more here" where a chevron reads as "there is something below",
* and in a list divided by rules the chevron's direction fights the rule it
* sits above. Rotation is the affordance, so reduced motion drops the
* transition and the icon still ends in the correct state.
*/
function FaqQuestion({
level = 3,
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger> & {
level?: 2 | 3 | 4 | 5 | 6
}) {
const Heading = `h${level}` as "h2" | "h3" | "h4" | "h5" | "h6"
return (
<AccordionPrimitive.Header render={<Heading />} data-slot="faq-header">
<AccordionPrimitive.Trigger
data-slot="faq-question"
className={cn(
"group flex w-full cursor-pointer items-start justify-between gap-4 py-4 text-start",
"text-base font-medium text-muted-foreground",
// `transition-[color]`, NOT `transition-colors`: nothing else on this
// trigger should fade, and the kit already learned that lesson on the
// Nav/Tabs pill, where a fading background landed on top of the
// arriving indicator.
//
// NO `motion-reduce:transition-none`, deliberately. Reduced motion in
// this kit is an allowlist, not a blanket kill: a colour change is
// non-vestibular and it is what SIGNALS the open item here, so
// suppressing it removes comprehension and gives back no comfort.
// Only the icon's rotation is vestibular, and only that is guarded.
"transition-[color] duration-200 ease-out",
"hover:text-foreground data-[panel-open]:text-foreground",
"rounded-sm ring-inset outline-none focus-visible:ring-[3px] focus-visible:ring-ring-focus",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
<PlusIcon
aria-hidden
className={cn(
// mt-0.5 optically centres the glyph on the first line of a
// question that wraps, which a self-centred icon would not do.
"mt-0.5 size-4 shrink-0",
// Paired elements move as one, so the icon carries the panel's
// duration in both directions. This used to override the duration
// per state to track an asymmetric panel; with one REVEAL_MS there
// is nothing to track, and the override was a second place for the
// number to drift.
"transition-transform duration-200 ease-out motion-reduce:transition-none",
"group-data-[panel-open]:rotate-45"
)}
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
/**
* The answer.
*
* Holds real content: paragraphs, lists, links, and non-anchor triggers (a
* button that opens a dialog) that still have to read as inline links. The
* kit's inline-link rule is applied HERE, by descendant selector, so a consumer
* never restyles a link by hand inside an answer and cannot get it wrong. A
* non-anchor trigger opts in with `data-inline-link`, which is what a
* `<button>` needs in order to sit inside a sentence without looking like a
* control. The `:hover` sits INSIDE the bracket: `hover:[&_a]` would compile to
* "when the ANSWER is hovered, every link inside it", which lights up every
* link at once.
*
* Height is animated from the content's own measured height, never a
* `max-height` guess. A guess is either too small (the answer clips) or too
* large (the reveal spends most of its duration animating empty space, which is
* why so many hand-rolled FAQs feel slow at the end). The measurement also
* survives an interrupted toggle, because the live height is read on each
* reversal rather than assumed to be zero.
*/
function FaqAnswer({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Panel>) {
const ref = React.useRef<HTMLDivElement>(null)
// Mechanism shared with Collapsible, and the reasoning is documented there in
// full: `keepMounted` stops Base UI from running its own height keyframe (it
// treats a kept-mounted panel as animation-type "none" and just toggles
// `hidden`), which leaves this free to own `height` and `display` and drive a
// single clean transition off a real measurement.
React.useEffect(() => {
const node = ref.current
if (!node) return
// An already-open panel must not keep the clip, or it shears the focus ring
// of any link or button inside it. Every focusable in the kit rings 1px
// outside its border box.
const syncOverflow = () => {
node.style.overflow = node.hasAttribute("data-open") ? "visible" : ""
}
syncOverflow()
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const reducedObserver = new MutationObserver(syncOverflow)
reducedObserver.observe(node, {
attributes: true,
attributeFilter: ["data-open", "data-closed", "hidden"],
})
return () => reducedObserver.disconnect()
}
let wasOpen = node.hasAttribute("data-open")
const settle = (e: TransitionEvent) => {
if (e.target !== node || e.propertyName !== "height") return
node.style.transition = ""
node.style.height = ""
node.style.removeProperty("display") // clears the important flag too
node.style.overflow = node.hasAttribute("data-open") ? "visible" : ""
}
const animate = (open: boolean) => {
const s = node.style
s.overflow = "" // re-arm the clip for the reveal; settle releases it
// `!important`, and it is the whole reason the close used to snap. Base
// UI sets the `hidden` ATTRIBUTE the moment the panel closes, and the UA
// stylesheet backs that with `display: none !important` — a rule that
// lives in no stylesheet you can read from `document.styleSheets`, so it
// is invisible to every check short of reading computed style in a live
// page. A plain inline `display: block` loses to it, which left the node
// laid out at ZERO height: `from` measured 0, the height went 0 to 0,
// and no transition ever ran. Measured: 0px without the flag, 98px with.
s.setProperty("display", "block", "important")
const from = s.height ? node.offsetHeight : open ? 0 : node.offsetHeight
s.height = "auto"
const to = open ? node.offsetHeight : 0
s.transition = "none"
s.height = `${from}px`
void node.getBoundingClientRect() // reflow so the next assignment animates
s.transition = `height ${REVEAL_MS}ms ${EASE_OUT}`
s.height = `${to}px`
}
const observer = new MutationObserver(() => {
const isOpen = node.hasAttribute("data-open")
if (isOpen === wasOpen) return
wasOpen = isOpen
animate(isOpen)
})
observer.observe(node, {
attributes: true,
attributeFilter: ["data-open", "data-closed", "hidden"],
})
node.addEventListener("transitionend", settle)
return () => {
observer.disconnect()
node.removeEventListener("transitionend", settle)
}
}, [])
return (
<AccordionPrimitive.Panel
ref={ref}
keepMounted
data-slot="faq-answer"
// The panel is MECHANISM, not treatment: its only job is to clip the
// reveal while the height animates, and its inline height/display are
// owned by the effect below. `className` therefore lands on the content
// div instead, which is where every override a caller actually wants
// (padding, measure, type) has to sit to beat what is set there. Applied
// to both, as it was, a caller's `pb-8` would be deduped against `pb-5`
// on one element and stack uselessly on the other.
className="overflow-hidden"
{...props}
>
<div
data-slot="faq-answer-content"
className={cn(
"max-w-measure pb-5 text-base leading-reading text-muted-foreground",
"[&>*+*]:mt-4",
// The kit's inline-link rule, applied to real anchors and to any
// non-anchor trigger that opts in with data-inline-link.
"[&_a]:text-accent [&_a]:underline [&_a]:decoration-from-font [&_a]:underline-offset-2 [&_a]:rounded-sm [&_a:hover]:no-underline",
"[&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-ring-accent [&_a:focus-visible]:outline-none",
"[&_[data-inline-link]]:cursor-pointer [&_[data-inline-link]]:text-accent [&_[data-inline-link]]:underline [&_[data-inline-link]]:decoration-from-font [&_[data-inline-link]]:underline-offset-2 [&_[data-inline-link]]:rounded-sm [&_[data-inline-link]:hover]:no-underline",
"[&_[data-inline-link]:focus-visible]:ring-2 [&_[data-inline-link]:focus-visible]:ring-ring-accent [&_[data-inline-link]:focus-visible]:outline-none",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Faq, FaqItem, FaqQuestion, FaqAnswer }