# FAQ section

Two-column FAQ with an animated accordion, contact prompt and FAQPage JSON-LD for rich results.

- Type: block
- Category: Sections
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/faq-section.json
```

## Usage

```tsx
import { FaqSection } from "@/components/facade/faq-section";

export default function Page() {
  return <FaqSection />;
}
```

## Source

```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;
```
