FAQ accordion
Questions on hairlines that open like they mean it: the answer eases to its height, the plus folds into a minus.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/faq-accordion.jsonOverview
The FAQ is where visitors decide, and most accordions jump open and slam shut. Here the answer eases open to its real height while its text settles in out of a slight blur, and the plus turns half a circle as its upright folds flat into a minus. Closing fades the words first, then lets the space go. In single mode, opening one question closes the other in the same motion.
States
- closed
- The question at 80% ink on a hairline, with a plus; hover brings it to full.
- opening
- The answer’s row grows to its real height (460ms) and its text rises 6px out of a 4px blur; the plus turns 180° into a minus.
- closing
- The words fade in 160ms, then the space closes, so text never gets squeezed.
- single / multiple
- One open at a time, or any number.
Usage
import { FaqAccordion } from "@/components/faq-accordion";
<FaqAccordion
defaultOpen={[0]}
items={[{ question: "Can I use it for client work?", answer: "Yes, one licence covers it." }]}
/>In a FAQ section
Answers can be any React: links, lists, small tables.
import { FaqAccordion } from "@/components/faq-accordion";
const faqs = [
{ question: "Can I get a refund?", answer: <>Within 14 days. <a href="/refunds" className="underline">Read the policy</a>.</> },
{ question: "Do you offer team plans?", answer: "Yes: five seats or more get 20% off." },
];
export function Faq() {
return (
<section className="mx-auto grid max-w-5xl gap-10 md:grid-cols-[1fr_1.4fr]">
<h2 className="text-3xl font-medium tracking-tight">Questions, answered</h2>
<FaqAccordion items={faqs} defaultOpen={[0]} />
</section>
);
}Props
| Prop | Type | Description |
|---|---|---|
| items | { question; answer; id? }[] | Each question, and its answer as text or any React. |
| type | "single" | "multiple"default "single" | One answer open at a time, or any number. |
| defaultOpen | number[]default [] | Questions open at first, by index. |
Accessibility and motion
- Each question is a heading with a button (aria-expanded, aria-controls); each answer is a labelled region, hidden from the tab order when closed.
- Up and Down move between questions, Home and End jump to the first and last, as the ARIA accordion pattern describes.
- Height is animated with grid rows, so answers can be any length without measuring.
- With reduced motion, answers open and close in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
Source
›Show the full source of faq-accordion.tsx (135 lines)
"use client";
import * as React from "react";
/* ─────────────────────────────────────────────────────────
* FAQ ACCORDION: questions that open like they mean it
*
* closed a question on a hairline, with a plus
* opening the answer eases open to its real height while its
* text settles in out of a slight blur; the plus
* turns and folds into a minus
* closing the answer fades first, then the space closes
* single opening one closes the other in the same motion
*
* Up and Down move between questions, Home and End jump.
* ───────────────────────────────────────────────────────── */
export interface FaqItem {
question: string;
answer: React.ReactNode;
id?: string;
}
export interface FaqAccordionProps extends React.HTMLAttributes<HTMLDivElement> {
items: FaqItem[];
/** One answer open at a time, or any number. */
type?: "single" | "multiple";
/** Questions open at first, by index. */
defaultOpen?: number[];
}
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
function PlusMinus({ open, reduced }: { open: boolean; reduced: boolean }) {
const t = reduced ? "none" : `transform 420ms ${EASE}, opacity 300ms ${EASE}`;
return (
<span aria-hidden="true" className="relative flex size-4 shrink-0 items-center justify-center" style={{ transform: open ? "rotate(180deg)" : "none", transition: t }}>
<span className="absolute h-px w-3 rounded-full bg-current" />
{/* The upright folds flat to make the minus. */}
<span className="absolute h-3 w-px rounded-full bg-current" style={{ transform: open ? "rotate(90deg) scaleY(0.4)" : "none", opacity: open ? 0 : 1, transition: t }} />
</span>
);
}
export function FaqAccordion({ items, type = "single", defaultOpen = [], className = "", ...props }: FaqAccordionProps) {
const reduced = useReducedMotion();
const id = React.useId();
const [open, setOpen] = React.useState<Set<number>>(() => new Set(type === "single" ? defaultOpen.slice(0, 1) : defaultOpen));
const buttons = React.useRef<(HTMLButtonElement | null)[]>([]);
const toggle = (i: number) =>
setOpen((prev) => {
const next = new Set(type === "single" ? [] : prev);
if (prev.has(i)) next.delete(i);
else next.add(i);
return next;
});
const onKeyDown = (e: React.KeyboardEvent, i: number) => {
const moves: Record<string, number> = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: items.length - 1 };
if (!(e.key in moves)) return;
e.preventDefault();
buttons.current[(moves[e.key] + items.length) % items.length]?.focus();
};
return (
<div className={`w-full border-b border-border ${className}`} {...props}>
{items.map((item, i) => {
const isOpen = open.has(i);
const key = item.id ?? `${id}-${i}`;
return (
<div key={key} className="border-t border-border">
<h3>
<button
ref={(el) => {
buttons.current[i] = el;
}}
type="button"
id={`${key}-q`}
aria-expanded={isOpen}
aria-controls={`${key}-a`}
onClick={() => toggle(i)}
onKeyDown={(e) => onKeyDown(e, i)}
className={`group flex w-full items-center justify-between gap-6 rounded-lg py-4 text-left text-[15px] outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40 ${
isOpen ? "text-foreground" : "text-foreground/80 hover:text-foreground"
}`}
>
<span className="font-medium tracking-[-0.01em]">{item.question}</span>
<span className={`transition-colors duration-300 ${isOpen ? "text-foreground" : "text-muted-foreground group-hover:text-foreground"}`}>
<PlusMinus open={isOpen} reduced={reduced} />
</span>
</button>
</h3>
<div
id={`${key}-a`}
role="region"
aria-labelledby={`${key}-q`}
inert={!isOpen}
className="grid"
style={{ gridTemplateRows: isOpen ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 460ms ${EASE}` }}
>
<div className="min-h-0 overflow-hidden">
<div
className="pb-5 pr-10 text-[14px] leading-relaxed text-muted-foreground"
style={{
opacity: isOpen ? 1 : 0,
filter: isOpen ? "none" : "blur(4px)",
transform: isOpen ? "none" : "translateY(-6px)",
transition: reduced
? "none"
: isOpen
? `opacity 420ms ${EASE} 90ms, filter 420ms ${EASE} 90ms, transform 520ms ${EASE} 60ms`
: `opacity 160ms ease-out, filter 160ms ease-out, transform 240ms ease-out`,
}}
>
{item.answer}
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
Registry item: /r/faq-accordion.json