bottega

sections

FAQ Accordion

A FAQ section with an eyebrow label, h2 heading, and optional subtext, followed by a stack of question/answer accordions. Each item is a full-width button (inside an h3 for heading hierarchy) with a chevron that rotates 180° when open; the answer panel expands with a smooth height + opacity animation via Motion's AnimatePresence. Single-open by default — opening one closes others; an allowMultiple prop enables multi-open. Built-in a11y: aria-expanded/controls on the button, role=region + aria-labelledby on the panel, focus-visible rings from --ring, keyboard toggle (Enter/Space). Item entrance staggered on scroll via useInView.

FAQs

Everything you need to know


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/faq-accordion

Usage

Usagetsx
<FaqAccordion />

Props

PropTypeDefaultDescription
eyebrowstringFAQs
headingstringEverything you need to know
subtextstring
itemsFaqItem[][ { question: "How do I install a component?", answer: "Run `npx shadcn add @bottega/<name>` in your project. The component source is copied directly into your codebase — no runtime dependency on Bottega is added. You own the file from the moment you install.", }, { question: "What does the copy-and-own model mean?", answer: "Unlike a traditional component library, Bottega ships source code, not a package to import. Once installed, the file lives in your project, is fully editable, and never requires a Bottega update cycle to maintain.", }, { question: "How do I apply my own brand colours?", answer: "Every Bottega component reads from a small set of CSS custom properties (--primary, --background, --foreground, --border, --radius, etc.). Map those tokens to your palette in your global CSS and every component updates automatically — no per-component overrides needed.", }, { question: "What is the licensing model?", answer: "Original Bottega components are MIT-licensed. Ported components note their upstream licence in the registry metadata, visible on each component's gallery page. Always check the source credit block before shipping a port in a commercial project.", }, { question: "Which frameworks does Bottega support?", answer: "Bottega targets Next.js 14+ with the App Router, but most components work in any React project using Tailwind v4 and the shadcn registry CLI. Three.js components additionally require @react-three/fiber and @react-three/drei.", }, ]
allowMultiplebooleanfalseAllow multiple panels open simultaneously. Defaults to false (single-open).
classNamestring

Source

faq-accordion.tsxtsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import {
  AnimatePresence,
  motion,
  useInView,
  useReducedMotion,
} from "motion/react";

export interface FaqItem {
  question: string;
  answer: string;
}

export interface FaqAccordionProps {
  eyebrow?: string;
  heading?: string;
  subtext?: string;
  items?: FaqItem[];
  /** Allow multiple panels open simultaneously. Defaults to false (single-open). */
  allowMultiple?: boolean;
  className?: string;
}

const DEFAULT_ITEMS: FaqItem[] = [
  {
    question: "How do I install a component?",
    answer:
      "Run `npx shadcn add @bottega/<name>` in your project. The component source is copied directly into your codebase — no runtime dependency on Bottega is added. You own the file from the moment you install.",
  },
  {
    question: "What does the copy-and-own model mean?",
    answer:
      "Unlike a traditional component library, Bottega ships source code, not a package to import. Once installed, the file lives in your project, is fully editable, and never requires a Bottega update cycle to maintain.",
  },
  {
    question: "How do I apply my own brand colours?",
    answer:
      "Every Bottega component reads from a small set of CSS custom properties (--primary, --background, --foreground, --border, --radius, etc.). Map those tokens to your palette in your global CSS and every component updates automatically — no per-component overrides needed.",
  },
  {
    question: "What is the licensing model?",
    answer:
      "Original Bottega components are MIT-licensed. Ported components note their upstream licence in the registry metadata, visible on each component's gallery page. Always check the source credit block before shipping a port in a commercial project.",
  },
  {
    question: "Which frameworks does Bottega support?",
    answer:
      "Bottega targets Next.js 14+ with the App Router, but most components work in any React project using Tailwind v4 and the shadcn registry CLI. Three.js components additionally require @react-three/fiber and @react-three/drei.",
  },
];

// Motion JS timings (seconds) — distinct from --duration CSS UI-transition token (~300ms).
const EASE_EXPO = [0.16, 1, 0.3, 1] as const;
const HEADER_DURATION = 0.5;
const ITEM_DURATION = 0.4;
const ITEM_STAGGER = 0.055; // 55ms between items on scroll entrance

// Muted text — no --muted-foreground token; use color-mix per contract.
const MUTED_COLOR =
  "color-mix(in oklch, var(--foreground) 70%, var(--background))";

function cx(...parts: (string | false | undefined)[]) {
  return parts.filter(Boolean).join(" ");
}

function ChevronDown() {
  return (
    <svg
      width="18"
      height="18"
      viewBox="0 0 18 18"
      fill="none"
      aria-hidden="true"
      focusable="false"
    >
      <path
        d="M4.5 7L9 11.5L13.5 7"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export function FaqAccordion({
  eyebrow = "FAQs",
  heading = "Everything you need to know",
  subtext,
  items = DEFAULT_ITEMS,
  allowMultiple = false,
  className,
}: FaqAccordionProps) {
  const uid = useId();
  const ref = useRef<HTMLElement>(null);

  // Mount-gate + null-safe reduced-motion check (contract pattern — avoids React #418).
  // SSR and first client render are static (mounted=false). Animation enables
  // post-hydration only for users who haven't opted into reduced motion.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;
  const animate = mounted && !reduce;

  // Scroll-entry reveal — fires once, triggers item stagger.
  const inView = useInView(ref, { once: true, amount: 0.15 });
  const show = animate && inView;

  // Tracks which indices are open. A Set supports both single- and multi-open modes.
  const [openSet, setOpenSet] = useState<Set<number>>(new Set());

  function toggle(index: number) {
    setOpenSet((prev) => {
      const next = new Set(prev);
      if (next.has(index)) {
        next.delete(index);
      } else {
        if (!allowMultiple) next.clear();
        next.add(index);
      }
      return next;
    });
  }

  return (
    <section
      ref={ref}
      className={cx(
        "w-full px-6 py-16 sm:px-8 sm:py-24",
        "bg-background text-foreground",
        className,
      )}
    >
      <div className="mx-auto max-w-2xl">
        {/* ── Section header ── */}
        <motion.div
          initial={animate ? { opacity: 0, y: 16 } : false}
          animate={
            show
              ? { opacity: 1, y: 0 }
              : animate
                ? { opacity: 0, y: 16 }
                : false
          }
          transition={{ duration: HEADER_DURATION, ease: EASE_EXPO }}
          className="mb-12 text-center"
        >
          {eyebrow && (
            <p
              className="mb-3 text-xs font-semibold uppercase tracking-widest"
              style={{ color: "var(--primary)" }}
            >
              {eyebrow}
            </p>
          )}
          <h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
            {heading}
          </h2>
          {subtext && (
            <p
              className="mt-4 text-base leading-relaxed"
              style={{ color: MUTED_COLOR }}
            >
              {subtext}
            </p>
          )}
        </motion.div>

        {/* ── Accordion list ── */}
        {/* Top border on the wrapper; each item carries its own bottom border. */}
        <div className="border-t border-border">
          {items.map((item, i) => {
            const isOpen = openSet.has(i);
            // Stable IDs for aria-controls / aria-labelledby wiring.
            const btnId = `${uid}btn${i}`;
            const panelId = `${uid}panel${i}`;

            return (
              <motion.div
                key={i}
                className="border-b border-border"
                // Entrance animation — values branch on animate, not DOM structure.
                initial={animate ? { opacity: 0, y: 8 } : false}
                animate={
                  show
                    ? { opacity: 1, y: 0 }
                    : animate
                      ? { opacity: 0, y: 8 }
                      : false
                }
                transition={{
                  duration: ITEM_DURATION,
                  ease: EASE_EXPO,
                  delay: show ? i * ITEM_STAGGER : 0,
                }}
              >
                {/* Question row — h3 provides heading structure under the section h2. */}
                <h3 className="text-base font-medium leading-none">
                  <button
                    id={btnId}
                    type="button"
                    aria-expanded={isOpen}
                    aria-controls={panelId}
                    onClick={() => toggle(i)}
                    className={cx(
                      "flex w-full items-center justify-between py-5",
                      "text-left text-sm font-medium sm:text-base text-foreground",
                      "transition-colors duration-[var(--duration)] ease-[var(--ease)]",
                      "hover:text-primary",
                      // Focus ring — rounded to match --radius token.
                      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                      "rounded-[var(--radius)]",
                    )}
                  >
                    <span>{item.question}</span>
                    {/* Chevron: rotation VALUE branches on isOpen; speed branches on animate. */}
                    <motion.span
                      aria-hidden="true"
                      animate={{ rotate: isOpen ? 180 : 0 }}
                      transition={{ duration: animate ? 0.25 : 0, ease: EASE_EXPO }}
                      className="ml-6 flex-shrink-0 opacity-50"
                      style={{ display: "flex", transformOrigin: "center" }}
                    >
                      <ChevronDown />
                    </motion.span>
                  </button>
                </h3>

                {/* Answer panel — id is always present so the button's aria-controls
                    resolves; the region role/label is exposed only when open so
                    collapsed items don't litter SR landmark nav with empty regions. */}
                <div
                  id={panelId}
                  role={isOpen ? "region" : undefined}
                  aria-labelledby={isOpen ? btnId : undefined}
                >
                  <AnimatePresence initial={false}>
                    {isOpen && (
                      <motion.div
                        key="panel"
                        // DOM structure is ALWAYS: AnimatePresence > motion.div > p.
                        // Only VALUES differ by animate flag — no conditional tag branching.
                        initial={
                          animate
                            ? { height: 0, opacity: 0 }
                            : { opacity: 0 }
                        }
                        animate={
                          animate
                            ? { height: "auto", opacity: 1 }
                            : { opacity: 1 }
                        }
                        exit={
                          animate
                            ? { height: 0, opacity: 0 }
                            : { opacity: 0 }
                        }
                        transition={
                          animate
                            ? {
                                height: { duration: 0.3, ease: EASE_EXPO },
                                opacity: { duration: 0.2 },
                              }
                            : { duration: 0 }
                        }
                        style={{ overflow: "hidden" }}
                      >
                        <p
                          className="pb-5 text-sm sm:text-base leading-relaxed"
                          style={{ color: MUTED_COLOR }}
                        >
                          {item.answer}
                        </p>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
              </motion.div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

Dependencies

  • motion
  • @bottega/tokens