FAQ
An animated accordion FAQ component with stacked and grid layouts, full light/dark mode, category grouping, and a bottom CTA strip.
Overview
The FAQSection component is a pure, composable accordion primitive designed to handle common questions with smooth animated transitions. It prioritizes a headless design philosophy, allowing for seamless integration into any layout. Every interaction is synchronized with unified light/dark mode aesthetics for a high-end feel.
Features
- Headless Design: No forced heading or badge — compose freely at the page level with full layout control.
- AnimatePresence Accordion: Height and opacity animate simultaneously using Framer Motion for a premium open/close feel.
- Stacked & Grid Layouts: Single-column stacked or two-column card grid, both fully responsive.
- Full Light/Dark Mode: Every class is paired with a dark variant, perfectly synchronized with next-themes.
- Category Grouping: Optional
showCategoriesprop renders group labels above each category cluster. - Multi-Open Support:
allowMultipleprop controls whether one or many items can be open simultaneously.
Preview
Installation
Method 1: CLI (Recommended)
npx shadcn@latest add https://zenblocks-three.vercel.app/r/faq.jsonMethod 2: Manual
- Install Dependencies
npm install framer-motion lucide-react-
Copy the Source Code
Create
components/zenblocks/faq.tsxand paste the following:
Click to expand source
"use client";
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* TYPES */
/* -------------------------------------------------------------------------- */
export type FAQItem = {
question: string;
answer: string;
category?: string;
};
export interface FAQProps {
items?: FAQItem[];
layout?: "stacked" | "grid";
allowMultiple?: boolean;
defaultOpen?: number[];
showCategories?: boolean;
className?: string;
}
/* -------------------------------------------------------------------------- */
/* CONSTANTS */
/* -------------------------------------------------------------------------- */
export const DEFAULT_FAQ_ITEMS: FAQItem[] = [
{
question: "Is ZenBlocks free?",
answer: "Core library is MIT licensed and completely free forever. Pro plan unlocks premium blocks, templates, and the Figma kit.",
category: "Pricing",
},
{
question: "Does it work with Vite and Remix?",
answer: "Works with any React-based framework including Next.js, Vite, Remix, and Gatsby. The shadcn CLI handles all setup automatically.",
category: "Compatibility",
},
{
question: "Can I use it alongside shadcn/ui?",
answer: "Fully compatible — both use Tailwind CSS so you can freely mix ZenBlocks animation components with shadcn functional primitives.",
category: "Compatibility",
},
{
question: "Does it work on mobile?",
answer: "Mobile-first is a core principle. Every component is touch-optimized and tested for 60fps on iOS and Android.",
category: "Compatibility",
},
{
question: "What animation libraries are required?",
answer: "GSAP and Framer Motion are the main dependencies. They are installed automatically when you run the CLI install command.",
category: "Installation",
},
{
question: "Is there a Figma kit?",
answer: "Yes — a complete Figma design kit with all components, variants, and design tokens is included in the Pro plan.",
category: "Pricing",
},
{
question: "Can I customize components after installing?",
answer: "Absolutely. The CLI copies source code directly into your project. You own it completely and can modify anything freely.",
category: "Customization",
},
{
question: "How do I update a component to the latest version?",
answer: "Re-run the CLI install command for that component. It will overwrite the existing file with the latest version.",
category: "Installation",
},
];
/* -------------------------------------------------------------------------- */
/* INTERNAL ITEM */
/* -------------------------------------------------------------------------- */
const FAQItemComponent = ({
item,
isOpen,
onToggle,
showDivider,
index,
}: {
item: FAQItem;
isOpen: boolean;
onToggle: () => void;
showDivider: boolean;
index: number;
}) => {
const panelId = `faq-panel-${index}`;
const triggerId = `faq-trigger-${index}`;
return (
<div className="w-full">
<button
id={triggerId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={onToggle}
className="group flex w-full items-center justify-between bg-transparent border-none cursor-pointer text-left py-5 px-0 transition-colors duration-200"
>
<span className="text-base font-semibold flex-1 pr-4 text-zinc-900 dark:text-zinc-100 group-hover:text-zinc-700 dark:group-hover:text-zinc-200 transition-colors">
{item.question}
</span>
<Plus
size={20}
className={cn(
"shrink-0 transition-transform duration-300 ease-in-out",
isOpen
? "rotate-45 text-blue-600 dark:text-blue-400"
: "rotate-0 text-zinc-400 dark:text-zinc-500"
)}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={panelId}
role="region"
aria-labelledby={triggerId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
style={{ overflow: "hidden" }}
>
<div className="pb-5">
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
{item.answer}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{showDivider && (
<div className="border-b border-zinc-200 dark:border-zinc-800" />
)}
</div>
);
};
/* -------------------------------------------------------------------------- */
/* MAIN SECTION */
/* -------------------------------------------------------------------------- */
export const FAQSection = ({
items = DEFAULT_FAQ_ITEMS,
layout = "stacked",
allowMultiple = false,
defaultOpen = [],
showCategories = false,
className,
}: FAQProps) => {
const [openItems, setOpenItems] = useState<Set<number>>(new Set(defaultOpen));
const handleToggle = (index: number) => {
const newOpenItems = new Set(openItems);
if (newOpenItems.has(index)) {
newOpenItems.delete(index);
} else {
if (!allowMultiple) {
newOpenItems.clear();
}
newOpenItems.add(index);
}
setOpenItems(newOpenItems);
};
const renderItems = () => {
if (layout === "grid") {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-5xl mx-auto">
{items.map((item, index) => (
<div
key={index}
className={cn(
"bg-zinc-50 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-2xl px-6 shadow-sm transition-all duration-300",
"hover:bg-zinc-100/50 dark:hover:bg-zinc-800/50 hover:border-zinc-300 dark:hover:border-zinc-700"
)}
>
<FAQItemComponent
item={item}
isOpen={openItems.has(index)}
onToggle={() => handleToggle(index)}
showDivider={false}
index={index}
/>
</div>
))}
</div>
);
}
if (showCategories) {
const categories = Array.from(new Set(items.map((i) => i.category || "General")));
return (
<div className="max-w-2xl mx-auto">
{categories.map((cat) => (
<div key={cat} className="mb-4">
<h3 className="text-xs uppercase tracking-widest font-semibold text-blue-600 dark:text-blue-400 mb-1 mt-8">
{cat}
</h3>
{items
.map((item, originalIndex) => ({ item, originalIndex }))
.filter(({ item }) => (item.category || "General") === cat)
.map(({ item, originalIndex }, catIndex, filteredList) => (
<FAQItemComponent
key={originalIndex}
item={item}
isOpen={openItems.has(originalIndex)}
onToggle={() => handleToggle(originalIndex)}
showDivider={catIndex !== filteredList.length - 1}
index={originalIndex}
/>
))}
</div>
))}
</div>
);
}
return (
<div className="max-w-2xl mx-auto">
{items.map((item, index) => (
<FAQItemComponent
key={index}
item={item}
isOpen={openItems.has(index)}
onToggle={() => handleToggle(index)}
showDivider={index !== items.length - 1}
index={index}
/>
))}
</div>
);
};
return (
<div className={cn("w-full", className)}>
{renderItems()}
{/* BOTTOM CTA */}
<div className="mt-16 text-center">
<h4 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
Still have questions?
</h4>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Join our Discord or open a GitHub issue.
</p>
<div className="flex gap-3 justify-center mt-5">
<button className="border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-xl px-5 py-2 text-sm font-medium transition-colors duration-200">
Join Discord
</button>
<button className="border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-xl px-5 py-2 text-sm font-medium transition-colors duration-200">
GitHub Issues
</button>
</div>
</div>
</div>
);
};
export default FAQSection;Usage
The component renders no heading. Compose it at the page level:
import { FAQSection } from "@/components/zenblocks/faq";
export default function Example() {
return (
<section className="py-24 px-6 bg-white dark:bg-zinc-950">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold text-zinc-900 dark:text-zinc-100">
Common questions.
</h2>
<p className="text-zinc-500 dark:text-zinc-400 mt-3">
Everything you need to know.
</p>
</div>
<FAQSection />
</section>
);
}Custom items with grid layout:
<FAQSection items={myItems} layout="grid" />Props
FAQSection
| Prop | Type | Default | Description |
|---|---|---|---|
items | FAQItem[] | DEFAULT_FAQ_ITEMS | Array of FAQ objects to display. |
layout | "stacked" | "grid" | "stacked" | Single-column or two-column card grid layout. |
allowMultiple | boolean | false | Allow multiple items open at the same time. |
defaultOpen | number[] | [] | Indices of items open on first render. |
showCategories | boolean | false | Render category group labels above each cluster. |
className | string | - | Additional CSS classes for the root div. |
FAQItem type
| Property | Type | Description |
|---|---|---|
question | string | The trigger text shown in the button. |
answer | string | The body text revealed on open. |
category | string | Optional group label for showCategories. |
Accessibility
- Native
<button>elements for all accordion triggers. aria-expandedon each trigger reflecting open/closed state.aria-controlslinking each trigger to its answer panelid.- Keyboard accessible —
EnterandSpacetoggle items. - Respects
prefers-reduced-motionvia Framer Motion defaults.
Performance
- Zero Hidden DOM: AnimatePresence fully unmounts closed panels — no hidden overflow accumulating in the DOM.
- Scoped Layout Shift: Height animation is scoped to each item wrapper, preventing full-page reflow.
- GPU Acceleration: Opacity transitions run on the compositor thread alongside height for smooth 60fps.