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-accordionUsage
Usagetsx
<FaqAccordion />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | string | FAQs | |
| heading | string | Everything you need to know | |
| subtext | string | — | |
| 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.", }, ] | |
| allowMultiple | boolean | false | Allow multiple panels open simultaneously. Defaults to false (single-open). |
| className | string | — |
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