Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, MessageCircle, Plus } from "lucide-react";
import { cn } from "@/lib/utils";

export type FaqItem = { question: string; answer: React.ReactNode };

export interface FaqAccordionProps {
  eyebrow?: string;
  title?: string;
  description?: string;
  items?: FaqItem[];
  /** Only one item open at a time (default true). */
  single?: boolean;
  /** Indexes open on first render. */
  defaultOpen?: number[];
  contact?: { label: string; href: string; note?: string } | null;
  className?: string;
}

const DEFAULT_ITEMS: FaqItem[] = [
  {
    question: "How does the 14-day free trial work?",
    answer: "You get full access to every Pro feature for 14 days — no credit card required. When the trial ends you can pick a plan or drop back to the free tier. Your data stays exactly where you left it.",
  },
  {
    question: "Can I change plans later?",
    answer: "Yes. Upgrades take effect immediately and are prorated to the day. Downgrades apply at the end of your current billing period, so you never lose features you've already paid for.",
  },
  {
    question: "Where is my data stored?",
    answer: "Customer data is stored in encrypted databases in the EU or US — you choose the region when creating a workspace. Backups are taken every hour and retained for 30 days.",
  },
  {
    question: "Do you offer discounts for startups and non-profits?",
    answer: "We do. Early-stage startups get 50% off the first year and registered non-profits get 30% off forever. Reach out to our team with a little info about your organization.",
  },
  {
    question: "Is there an API?",
    answer: "Every action in the app is available through our REST and GraphQL APIs, with typed SDKs for TypeScript, Python and Go. Rate limits are generous and listed on each plan.",
  },
  {
    question: "What happens if I cancel?",
    answer: "You can cancel anytime from billing settings in two clicks. You'll keep access until the end of the period and can export everything as CSV or JSON before you go.",
  },
];

export function FaqAccordion({
  eyebrow = "FAQ",
  title = "Questions, answered",
  description = "Everything you need to know about the product and billing. Can't find what you're looking for? Our team replies within a few hours.",
  items = DEFAULT_ITEMS,
  single = true,
  defaultOpen = [0],
  contact = { label: "Chat with our team", href: "#contact", note: "Average reply time: 2 hours" },
  className,
}: FaqAccordionProps) {
  const [open, setOpen] = React.useState<Set<number>>(() => new Set(single ? defaultOpen.slice(0, 1) : defaultOpen));
  const baseId = React.useId();
  const buttons = React.useRef<(HTMLButtonElement | null)[]>([]);
  const reduce = useReducedMotion();

  const toggle = (i: number) =>
    setOpen((prev) => {
      const next = new Set(single ? [] : prev);
      if (!prev.has(i)) next.add(i);
      return next;
    });

  const onKeyDown = (e: React.KeyboardEvent, i: number) => {
    const n = items.length;
    const focus = (j: number) => buttons.current[(j + n) % n]?.focus();
    const keys: Record<string, number> = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: n - 1 };
    const target = keys[e.key];
    if (target === undefined) return;
    e.preventDefault();
    focus(target);
  };

  return (
    <section className={cn("relative w-full bg-background px-4 py-16 sm:px-6 sm:py-24", className)}>
      <div className="mx-auto grid max-w-6xl gap-12 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-20">
        <div className="lg:sticky lg:top-12 lg:self-start">
          <p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
          <h2 className="mt-3 text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
          <p className="mt-5 max-w-md text-pretty text-base leading-relaxed text-muted-foreground">{description}</p>
          {contact && (
            <a
              href={contact.href}
              className="group mt-8 flex max-w-sm items-center gap-4 rounded-2xl border bg-card p-4 transition hover:border-primary/40 hover:shadow-lg hover:shadow-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-500 text-white shadow-md">
                <MessageCircle aria-hidden className="size-5" />
              </span>
              <span className="min-w-0 flex-1">
                <span className="block font-semibold text-foreground">{contact.label}</span>
                {contact.note && <span className="block text-sm text-muted-foreground">{contact.note}</span>}
              </span>
              <ArrowUpRight aria-hidden className="size-5 text-muted-foreground transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-foreground" />
            </a>
          )}
        </div>

        <div className="divide-y border-y">
          {items.map((item, i) => {
            const isOpen = open.has(i);
            const btnId = `${baseId}-q${i}`;
            const panelId = `${baseId}-a${i}`;
            return (
              <div key={i} className="relative">
                <h3>
                  <button
                    ref={(el) => {
                      buttons.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-center justify-between gap-6 rounded-lg py-6 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background"
                  >
                    <span className={cn("text-base font-medium transition-colors sm:text-lg", isOpen ? "text-foreground" : "text-foreground/80 group-hover:text-foreground")}>
                      {item.question}
                    </span>
                    <motion.span
                      aria-hidden
                      animate={{ rotate: isOpen ? 135 : 0 }}
                      transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 20 }}
                      className={cn(
                        "grid size-8 shrink-0 place-items-center rounded-full border transition-colors",
                        isOpen ? "border-transparent bg-primary text-primary-foreground" : "bg-card text-muted-foreground group-hover:text-foreground",
                      )}
                    >
                      <Plus className="size-4" strokeWidth={2.25} />
                    </motion.span>
                  </button>
                </h3>
                <AnimatePresence initial={false}>
                  {isOpen && (
                    <motion.div
                      id={panelId}
                      role="region"
                      aria-labelledby={btnId}
                      key="panel"
                      initial={{ height: 0, opacity: 0 }}
                      animate={{ height: "auto", opacity: 1 }}
                      exit={{ height: 0, opacity: 0 }}
                      transition={reduce ? { duration: 0 } : { height: { duration: 0.35, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.25 } }}
                      className="overflow-hidden"
                    >
                      <motion.div
                        initial={{ y: reduce ? 0 : -6 }}
                        animate={{ y: 0 }}
                        exit={{ y: reduce ? 0 : -6 }}
                        className="pb-6 pr-2 text-pretty sm:pr-12 leading-relaxed text-muted-foreground"
                      >
                        {item.answer}
                      </motion.div>
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

More in FAQ

View all →