Pattern
FAQ accordion
Questions with one answer open at a time.
betav0.2.0 Chromium 103 ui-scale safe
Install
Copies the file into
components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.bunx shadcn@3 add @fastrp/faq-accordionExamples
Default
GTA V’in yasal bir kopyası, FiveM ve bir FAST hesabı. Hepsi birkaç dakika.
API
<FaqAccordion />The questions a newcomer asks before joining, one answer open at a time. Keep the same list in the page's FAQPage structured data, so search results and the page never disagree.
| Prop | Type | Default | Notes |
|---|---|---|---|
| items* | FaqItem[] | — | |
| className | string | — | |
| defaultOpen | number | 0 | Which answer starts open; `null` for none. |
Dependencies
npm
- @fastrp/design-tokens
- lucide-react@^0.469.0
- motion@^12.40.0
Registry
- @fastrp/ui-sounds
- @fastrp/utils
Source
components/ds/faq-accordion.tsxShowHide
'use client';
import { DURATION_S, EASE } from '@fastrp/design-tokens/motion';
import { Plus } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type * as React from 'react';
import { useId, useState } from 'react';
import { useUiSound } from '@/components/ds/ui-sounds';
import { cn } from '@/lib/utils';
// @fast-ds [email protected]
export type FaqItem = { question: string; answer: React.ReactNode };
export type FaqAccordionProps = {
items: FaqItem[];
/** Which answer starts open; `null` for none. */
defaultOpen?: number | null;
className?: string;
};
function FaqRow({ item, open, onToggle }: { item: FaqItem; open: boolean; onToggle: () => void }) {
const id = useId();
const buttonId = `${id}-question`;
const panelId = `${id}-answer`;
const sound = useUiSound();
return (
<div className="border-t border-line last:border-b">
<h3>
<button
id={buttonId}
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => {
sound(open ? 'close' : 'open');
onToggle();
}}
className="group flex w-full select-none items-center justify-between gap-6 py-6 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<span
className={cn(
'font-display text-2xl transition-colors duration-fast md:text-3xl',
open ? 'text-foreground' : 'text-foreground/70 group-hover:text-foreground',
)}
>
{item.question}
</span>
<span className="glass shrink-0 p-2 text-foreground">
<Plus
className={cn('size-4 transition-transform duration-slow', open && 'rotate-45')}
/>
</span>
</button>
</h3>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={panelId}
role="region"
aria-labelledby={buttonId}
className="overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: DURATION_S.slow, ease: EASE }}
>
<div className="max-w-2xl pb-8 text-base leading-relaxed text-muted-foreground">
{item.answer}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/**
* The questions a newcomer asks before joining, one answer open at a time. Keep the same list in
* the page's FAQPage structured data, so search results and the page never disagree.
*/
function FaqAccordion({ items, defaultOpen = 0, className }: FaqAccordionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(defaultOpen);
return (
<div data-slot="faq-accordion" className={className}>
{items.map((item, index) => (
<FaqRow
key={item.question}
item={item}
open={openIndex === index}
onToggle={() => setOpenIndex((current) => (current === index ? null : index))}
/>
))}
</div>
);
}
export { FaqAccordion };