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-accordion

Examples

Default

Open alone

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.

PropTypeDefaultNotes
items*FaqItem[]—
classNamestring—
defaultOpennumber0Which answer starts open; `null` for none.

Dependencies

npm

  • @fastrp/design-tokens
  • lucide-react@^0.469.0
  • motion@^12.40.0

Registry

Source

components/ds/faq-accordion.tsxShow
'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 };