Browse the library
Blocks / Sections
FAQ section
FreeTwo-column FAQ with an animated accordion, contact prompt and FAQPage JSON-LD for rich results.
components/facade/faq-section.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, Mail, MessageCircle, Phone, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FaqItem {
question: string;
/** Plain-text answer. Also used verbatim for the JSON-LD output. */
answer: string;
}
export interface FaqSectionProps {
eyebrow?: string;
heading?: string;
description?: string;
items?: FaqItem[];
contactHeading?: string;
contactText?: string;
phone?: string;
email?: string;
ctaLabel?: string;
ctaHref?: string;
/** Index of the item open on first render. Use -1 for none. */
defaultOpen?: number;
/** Allow several answers to be open at the same time. */
multiple?: boolean;
/** Render a schema.org FAQPage JSON-LD script tag. */
jsonLd?: boolean;
className?: string;
}
const DEFAULT_ITEMS: FaqItem[] = [
{
question: "How quickly can you get someone out to my house?",
answer:
"Most Denver metro calls get a technician the same day if you reach us before 2 PM. For no-heat or no-cooling emergencies we run a 24/7 on-call crew, and the average arrival time last winter was 74 minutes.",
},
{
question: "Do you charge for estimates?",
answer:
"Estimates for new system installs and replacements are always free. Diagnostic visits for repairs are a flat $89, and that fee is waived if you approve the repair while the technician is on site.",
},
{
question: "Which brands do you install and service?",
answer:
"We are a Carrier Factory Authorized Dealer and also install Trane, Lennox and Mitsubishi ductless systems. Our technicians service every major brand, including older units that are no longer in production.",
},
{
question: "Is financing available for a new furnace or AC?",
answer:
"Yes. We offer 0% APR for 18 months and low monthly payment plans up to 120 months through our lending partners, subject to credit approval. Most customers get a decision in under five minutes.",
},
{
question: "Are your technicians licensed and insured?",
answer:
"Every technician is NATE certified, background checked and drug tested. We are fully licensed in the City and County of Denver and carry $2M in general liability insurance.",
},
{
question: "What does your warranty cover?",
answer:
"New installations include a 10-year parts and labor warranty and our 1-year no-questions satisfaction guarantee. Repairs are covered for 12 months on both parts and workmanship.",
},
];
export function FaqSection({
eyebrow = "FAQ",
heading = "Questions Denver homeowners ask us",
description = "Straight answers about pricing, scheduling and warranties. Can't find what you need? Our team is one call away.",
items = DEFAULT_ITEMS,
contactHeading = "Still have questions?",
contactText = "Talk to a real person at our Englewood office, Monday to Saturday.",
phone = "(303) 555-0148",
email = "hello@frontrangeair.com",
ctaLabel = "Schedule a visit",
ctaHref = "#",
defaultOpen = 0,
multiple = false,
jsonLd = true,
className,
}: FaqSectionProps) {
const reduce = useReducedMotion() ?? false;
const baseId = React.useId();
const [open, setOpen] = React.useState<number[]>(defaultOpen >= 0 ? [defaultOpen] : []);
const buttonRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const toggle = (i: number) => {
setOpen((prev) => {
const isOpen = prev.includes(i);
if (multiple) return isOpen ? prev.filter((x) => x !== i) : [...prev, i];
return isOpen ? [] : [i];
});
};
const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, i: number) => {
let next = -1;
if (e.key === "ArrowDown") next = (i + 1) % items.length;
else if (e.key === "ArrowUp") next = (i - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
if (next >= 0) {
e.preventDefault();
buttonRefs.current[next]?.focus();
}
};
const schema = React.useMemo(
() =>
JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((it) => ({
"@type": "Question",
name: it.question,
acceptedAnswer: { "@type": "Answer", text: it.answer },
})),
}).replace(/</g, "\\u003c"),
[items],
);
const telHref = `tel:${phone.replace(/[^\d+]/g, "")}`;
return (
<section className={cn("relative bg-background py-20 sm:py-28", className)}>
{jsonLd && <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: schema }} />}
<div className="mx-auto grid max-w-6xl gap-12 px-4 sm:px-6 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-16 lg:px-8">
<div className="lg:sticky lg:top-24 lg:self-start">
<p className="text-sm font-medium text-brand">{eyebrow}</p>
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">
{heading}
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">{description}</p>
<div className="mt-10 rounded-2xl border border-border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-xl bg-brand/10 text-brand">
<MessageCircle className="size-5" aria-hidden />
</span>
<div>
<h3 className="font-semibold text-foreground">{contactHeading}</h3>
<p className="text-sm text-muted-foreground">{contactText}</p>
</div>
</div>
<div className="mt-5 grid gap-2 text-sm">
{phone && (
<a
href={telHref}
className="flex items-center gap-3 rounded-lg px-2 py-2 text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Phone className="size-4 text-muted-foreground" aria-hidden />
<span className="font-mono">{phone}</span>
</a>
)}
{email && (
<a
href={`mailto:${email}`}
className="flex items-center gap-3 rounded-lg px-2 py-2 text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Mail className="size-4 text-muted-foreground" aria-hidden />
<span className="truncate">{email}</span>
</a>
)}
</div>
{ctaLabel && (
<a
href={ctaHref}
className="group mt-5 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-primary px-5 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{ctaLabel}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
</a>
)}
</div>
</div>
<div className="divide-y divide-border border-y border-border">
{items.map((item, i) => {
const isOpen = open.includes(i);
const btnId = `${baseId}-q-${i}`;
const panelId = `${baseId}-a-${i}`;
return (
<div key={item.question}>
<h3>
<button
ref={(el) => {
buttonRefs.current[i] = el;
}}
id={btnId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(i)}
onKeyDown={(e) => onKeyDown(e, i)}
className="group flex w-full items-start justify-between gap-6 rounded-lg py-6 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={cn(
"text-base font-semibold transition-colors sm:text-lg",
isOpen ? "text-foreground" : "text-foreground/80 group-hover:text-foreground",
)}
>
{item.question}
</span>
<span
className={cn(
"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border transition-colors",
isOpen
? "border-brand bg-brand text-brand-foreground"
: "border-border text-muted-foreground group-hover:border-foreground/30",
)}
>
<motion.span
animate={{ rotate: isOpen ? 45 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 30 }}
className="flex"
>
<Plus className="size-4" aria-hidden />
</motion.span>
</span>
</button>
</h3>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={panelId}
role="region"
aria-labelledby={btnId}
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<p className="max-w-2xl pb-6 pr-12 text-sm leading-relaxed text-muted-foreground sm:text-base">
{item.answer}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
</section>
);
}
export default FaqSection;app/page.tsx
import { FaqSection } from "@/components/facade/faq-section";
export default function Demo() {
return <FaqSection />;
}Installation
$
npx shadcn@latest add https://facadeui.com/r/faq-section.jsonUsage
import { FaqSection } from "@/components/facade/faq-section";
export default function Page() {
return <FaqSection />;
}Props
FaqSectionProps is exported, so you can extend or wrap it. Every prop has a default; <FaqSection /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| eyebrow | string | |
| heading | string | |
| description | string | |
| items | FaqItem[] | |
| contactHeading | string | |
| contactText | string | |
| phone | string | |
| string | ||
| ctaLabel | string | |
| ctaHref | string | |
| defaultOpen | number | Index of the item open on first render. Use -1 for none. |
| multiple | boolean | Allow several answers to be open at the same time. |
| jsonLd | boolean | Render a schema.org FAQPage JSON-LD script tag. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade faq section to this page and adapt the copy to our business.