Sections

FAQ Section

Two-column FAQ with a grid-rows accordion, plus-to-minus morphing icon, and one item open by default.

Install

npx shadcn@latest add @paragon/faq-section

faq-section.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const REVEAL =
  "transition-[opacity,translate,filter] duration-500 ease-out motion-reduce:transition-none";
const HIDDEN = "translate-y-3 opacity-0 blur-[4px]";
const SHOWN = "translate-y-0 opacity-100 blur-[0px]";

const FAQS = [
  {
    question: "How does pricing work?",
    answer:
      "Corridor is priced per active entity per month, with unlimited seats on every plan. You only pay for subsidiaries that post transactions in a given month — dormant entities are free.",
  },
  {
    question: "Is our financial data secure?",
    answer:
      "Yes. Corridor is SOC 2 Type II audited, encrypts data in transit and at rest, and supports SSO, SCIM, and customer-managed encryption keys on the Scale plan. Production access is gated behind hardware-key MFA.",
  },
  {
    question: "How long does migration from our current system take?",
    answer:
      "Most teams migrate in two to three weeks. We import your chart of accounts, open POs, and approval chains directly from NetSuite, QuickBooks, or CSV, and run both systems in parallel for one close before cutover.",
  },
  {
    question: "Do you require annual contracts?",
    answer:
      "No. Starter and Growth are available monthly, cancel anytime. Annual commitments carry roughly a 20% discount and lock pricing for the term.",
  },
  {
    question: "What support is included?",
    answer:
      "Every plan includes same-day support during business hours. Growth adds a shared Slack channel with a two-hour response SLA; Scale adds a named success engineer and quarterly close reviews.",
  },
];

/* Plus that morphs to minus: the vertical bar rotates 90° into the
   horizontal one. Individual transform properties (translate, rotate)
   compose in fixed order, so the bar spins about its own center. */
function PlusMinus({ open }: { open: boolean }) {
  return (
    <span
      aria-hidden
      className="relative size-4 shrink-0 text-muted-foreground"
    >
      <span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 rounded-full bg-current" />
      <span
        className={cn(
          "absolute top-0 left-1/2 h-full w-px -translate-x-1/2 rounded-full bg-current transition-[rotate] duration-250 ease-out motion-reduce:transition-none",
          open && "rotate-90",
        )}
      />
    </span>
  );
}

function FaqItem({
  faq,
  open,
  onToggle,
  baseId,
}: {
  faq: (typeof FAQS)[number];
  open: boolean;
  onToggle: () => void;
  baseId: string;
}) {
  const buttonId = `${baseId}-button`;
  const panelId = `${baseId}-panel`;

  return (
    <div className="border-b border-border">
      <h3>
        <button
          type="button"
          id={buttonId}
          aria-expanded={open}
          aria-controls={panelId}
          onClick={onToggle}
          className="flex w-full items-center justify-between gap-4 py-4 text-left text-sm font-medium transition-[color] duration-150 ease-out hover:text-muted-foreground"
        >
          {faq.question}
          <PlusMinus open={open} />
        </button>
      </h3>
      {/* Accordion via grid-template-rows 0fr↔1fr — the sanctioned exception. */}
      <div
        id={panelId}
        role="region"
        aria-labelledby={buttonId}
        className="grid transition-[grid-template-rows] duration-250 ease-out motion-reduce:transition-none"
        style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
      >
        <div className="overflow-hidden">
          <p
            className={cn(
              "pb-4 text-sm text-pretty text-muted-foreground transition-[opacity] duration-250 ease-out",
              open ? "opacity-100" : "opacity-0",
            )}
          >
            {faq.answer}
          </p>
        </div>
      </div>
    </div>
  );
}

export interface FaqSectionProps extends React.ComponentProps<"section"> {
  eyebrow?: string;
  headline?: string;
  subcopy?: string;
}

/**
 * Two-column FAQ: intro copy and contact link on the left, a grid-rows
 * accordion on the right with a plus→minus morphing icon. The first item
 * is open by default; items stagger in on first reveal.
 */
export function FaqSection({
  eyebrow = "FAQ",
  headline = "Answers before you ask",
  subcopy = "Everything procurement and finance leads ask us before rolling out Corridor. If it's not here, we're quick on email.",
  className,
  ...props
}: FaqSectionProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const ref = React.useRef<HTMLElement>(null);
  const inView = useInView(ref, { once: true, margin: "-80px" });
  const reduced = useReducedMotion();
  const revealed = inView || !!reduced;
  const [openIndex, setOpenIndex] = React.useState<number | null>(0);

  return (
    <section
      ref={ref}
      aria-labelledby="faq-section-headline"
      className={cn("w-full bg-background px-4 py-16 sm:px-6 md:py-24", className)}
      {...props}
    >
      <div className="mx-auto grid w-full max-w-5xl gap-10 lg:grid-cols-[2fr_3fr] lg:gap-16">
        <div className={cn("lg:pt-1", REVEAL, revealed ? SHOWN : HIDDEN)}>
          <p className="text-sm font-medium text-muted-foreground">{eyebrow}</p>
          <h2
            id="faq-section-headline"
            className="mt-2 text-3xl font-semibold tracking-tight text-balance sm:text-4xl"
          >
            {headline}
          </h2>
          <p className="mt-4 max-w-sm text-base text-pretty text-muted-foreground">
            {subcopy}
          </p>
          <a
            href="#"
            className="mt-6 inline-flex items-center gap-1 text-sm font-medium transition-[color] duration-150 ease-out hover:text-muted-foreground"
          >
            Contact our team
            <span aria-hidden>→</span>
          </a>
        </div>

        <div className="border-t border-border">
          {FAQS.map((faq, i) => (
            <div
              key={faq.question}
              className={cn(REVEAL, revealed ? SHOWN : HIDDEN)}
              style={{ transitionDelay: `${80 + i * 60}ms` }}
            >
              <FaqItem
                faq={faq}
                baseId={`faq-${id}-${i}`}
                open={openIndex === i}
                onToggle={() =>
                  setOpenIndex((current) => (current === i ? null : i))
                }
              />
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}