Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Search, SearchX, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type SearchFaqItem = { question: string; answer: string; category: string };

export interface FaqSearchProps {
  eyebrow?: string;
  title?: string;
  subtitle?: string;
  items?: SearchFaqItem[];
  /** Category order for the chips. Defaults to the order they appear in `items`. */
  categories?: string[];
  allLabel?: string;
  placeholder?: string;
  className?: string;
}

const DEFAULT_ITEMS: SearchFaqItem[] = [
  { category: "Billing", question: "Which payment methods do you accept?", answer: "All major credit and debit cards, SEPA direct debit and bank transfer on annual plans. Invoices are issued automatically every billing cycle." },
  { category: "Billing", question: "Can I get a refund?", answer: "Annual plans can be refunded in full within 30 days of purchase. Monthly plans can be cancelled at any time and won't renew." },
  { category: "Billing", question: "Do prices include VAT?", answer: "Prices are shown excluding VAT. Tax is calculated at checkout based on your billing address, and reverse charge applies for valid EU VAT IDs." },
  { category: "Account", question: "How do I invite teammates?", answer: "Open Settings → Members and send invites by email. Teammates can join with a magic link or single sign-on if your workspace has it enabled." },
  { category: "Account", question: "Can I transfer workspace ownership?", answer: "Yes. The current owner can transfer ownership to any admin from Settings → Workspace. The new owner receives an email to confirm." },
  { category: "Account", question: "How do I delete my account?", answer: "Go to Settings → Profile → Delete account. All personal data is removed within 30 days, and you can export your data before deleting." },
  { category: "Product", question: "Does it work offline?", answer: "The desktop and mobile apps cache your recent projects, so you can keep working offline. Changes sync automatically when you reconnect." },
  { category: "Product", question: "Which integrations are available?", answer: "Over 80 native integrations including calendars, chat, storage and CRM tools, plus webhooks and a public API for anything custom." },
  { category: "Product", question: "Is there a limit on file uploads?", answer: "Free workspaces can upload files up to 25 MB. Paid plans raise the limit to 2 GB per file with unlimited total storage." },
  { category: "Security", question: "Is my data encrypted?", answer: "Data is encrypted in transit with TLS 1.3 and at rest with AES-256. Encryption keys are rotated automatically every 90 days." },
  { category: "Security", question: "Do you support single sign-on?", answer: "SAML and OIDC single sign-on are available on the Business plan, along with SCIM provisioning and enforced two-factor authentication." },
  { category: "Security", question: "Where are your servers located?", answer: "You can choose EU (Frankfurt) or US (Virginia) data residency when you create a workspace. Backups stay in the same region." },
];

function escapeRegExp(s: string) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function Highlight({ text, query }: { text: string; query: string }) {
  if (!query) return <>{text}</>;
  const parts = text.split(new RegExp(`(${escapeRegExp(query)})`, "gi"));
  return (
    <>
      {parts.map((p, i) =>
        p.toLowerCase() === query.toLowerCase() ? (
          <mark key={i} className="rounded-[4px] bg-amber-300/70 px-0.5 text-foreground dark:bg-amber-400/35">
            {p}
          </mark>
        ) : (
          <React.Fragment key={i}>{p}</React.Fragment>
        ),
      )}
    </>
  );
}

export function FaqSearch({
  eyebrow = "Help center",
  title = "How can we help?",
  subtitle = "Search our most common questions or browse by topic.",
  items = DEFAULT_ITEMS,
  categories,
  allLabel = "All",
  placeholder = "Search questions…",
  className,
}: FaqSearchProps) {
  const reduce = useReducedMotion();
  const [query, setQuery] = React.useState("");
  const [category, setCategory] = React.useState<string>(allLabel);
  const [open, setOpen] = React.useState<Set<string>>(() => new Set());
  const inputRef = React.useRef<HTMLInputElement>(null);
  const baseId = React.useId();

  const cats = React.useMemo(() => [allLabel, ...(categories ?? Array.from(new Set(items.map((i) => i.category))))], [allLabel, categories, items]);
  const q = query.trim();

  const inCategory = (i: SearchFaqItem) => category === allLabel || i.category === category;
  const matches = (i: SearchFaqItem) => !q || `${i.question} ${i.answer}`.toLowerCase().includes(q.toLowerCase());

  // Question matches rank above answer-only matches so the list visibly reorders while typing.
  const results = items
    .filter((i) => inCategory(i) && matches(i))
    .map((i) => ({ item: i, score: q && i.question.toLowerCase().includes(q.toLowerCase()) ? 0 : 1 }))
    .sort((a, b) => a.score - b.score)
    .map((r) => r.item);

  const countFor = (c: string) => items.filter((i) => (c === allLabel || i.category === c) && matches(i)).length;

  // "/" focuses the search box, like most docs sites.
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement | null;
      if (e.key === "/" && target && !["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) {
        e.preventDefault();
        inputRef.current?.focus();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  const toggle = (key: string) =>
    setOpen((prev) => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });

  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 38 };

  return (
    <section className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}>
      <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-80 bg-[radial-gradient(55%_100%_at_50%_0%,color-mix(in_oklch,var(--primary)_13%,transparent),transparent)]" />
      <div className="relative mx-auto max-w-3xl">
        <header className="text-center">
          <p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
          <h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
          <p className="mt-4 text-pretty text-base text-muted-foreground sm:text-lg">{subtitle}</p>
        </header>

        <div role="search" className="mt-10">
          <label htmlFor={`${baseId}-q`} className="sr-only">
            Search questions
          </label>
          <div className="group relative">
            <Search aria-hidden className="pointer-events-none absolute left-5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary" />
            <input
              ref={inputRef}
              id={`${baseId}-q`}
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => e.key === "Escape" && setQuery("")}
              placeholder={placeholder}
              autoComplete="off"
              aria-describedby={`${baseId}-count`}
              className="h-14 w-full rounded-2xl border bg-card pl-14 pr-24 text-base text-foreground shadow-sm outline-none transition placeholder:text-muted-foreground/80 focus:border-primary/50 focus:shadow-lg focus:shadow-primary/10 focus:ring-4 focus:ring-primary/15 [&::-webkit-search-cancel-button]:appearance-none"
            />
            <div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2">
              <AnimatePresence initial={false}>
                {query ? (
                  <motion.button
                    key="clear"
                    type="button"
                    initial={{ opacity: 0, scale: 0.8 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={{ opacity: 0, scale: 0.8 }}
                    onClick={() => {
                      setQuery("");
                      inputRef.current?.focus();
                    }}
                    aria-label="Clear search"
                    className="grid size-8 place-items-center rounded-full bg-muted text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    <X className="size-4" />
                  </motion.button>
                ) : (
                  <motion.kbd
                    key="kbd"
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={{ opacity: 0 }}
                    className="hidden rounded-md border bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground sm:block"
                  >
                    /
                  </motion.kbd>
                )}
              </AnimatePresence>
            </div>
          </div>
        </div>

        <LayoutGroup id={baseId}>
          <div className="mt-5 flex flex-wrap justify-center gap-2" role="group" aria-label="Filter by topic">
            {cats.map((c) => {
              const active = c === category;
              const n = countFor(c);
              return (
                <button
                  key={c}
                  type="button"
                  aria-pressed={active}
                  onClick={() => setCategory(c)}
                  className={cn(
                    "relative inline-flex h-9 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                    active ? "text-primary-foreground" : "border bg-card text-muted-foreground hover:text-foreground",
                  )}
                >
                  {active && <motion.span layoutId="faq-search-chip" className="absolute inset-0 rounded-full bg-primary shadow-md shadow-primary/25" transition={spring} />}
                  <span className="relative">{c}</span>
                  <span className={cn("relative rounded-full px-1.5 text-[11px] tabular-nums", active ? "bg-primary-foreground/20" : "bg-muted")}>{n}</span>
                </button>
              );
            })}
          </div>

          <p id={`${baseId}-count`} className="sr-only" aria-live="polite">
            {results.length} {results.length === 1 ? "question" : "questions"} found
          </p>

          <motion.ul layout={!reduce} className="relative mt-8 space-y-3">
            <AnimatePresence mode="popLayout" initial={false}>
              {results.map((item) => {
                const key = item.question;
                const answerHit = !!q && item.answer.toLowerCase().includes(q.toLowerCase());
                const isOpen = open.has(key) || answerHit;
                const panelId = `${baseId}-${items.indexOf(item)}`;
                return (
                  <motion.li
                    key={key}
                    layout={!reduce}
                    initial={{ opacity: 0, scale: 0.97, y: 8 }}
                    animate={{ opacity: 1, scale: 1, y: 0 }}
                    exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.15 } }}
                    transition={spring}
                    className={cn("overflow-hidden rounded-2xl border bg-card transition-colors", isOpen && "border-primary/30 shadow-sm")}
                  >
                    <motion.button
                      layout="position"
                      type="button"
                      aria-expanded={isOpen}
                      aria-controls={panelId}
                      onClick={() => toggle(key)}
                      className="flex w-full items-center gap-4 px-5 py-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:px-6"
                    >
                      <span aria-hidden className="hidden w-20 shrink-0 rounded-md bg-muted py-0.5 text-center text-[11px] font-medium uppercase tracking-wide text-muted-foreground sm:inline">{item.category}</span>
                      <span className="flex-1 font-medium text-foreground">
                        <Highlight text={item.question} query={q} />
                      </span>
                      <ChevronDown aria-hidden className={cn("size-4 shrink-0 text-muted-foreground transition-transform duration-300", isOpen && "rotate-180")} />
                    </motion.button>
                    <AnimatePresence initial={false}>
                      {isOpen && (
                        <motion.div
                          id={panelId}
                          key="a"
                          initial={{ height: 0, opacity: 0 }}
                          animate={{ height: "auto", opacity: 1 }}
                          exit={{ height: 0, opacity: 0 }}
                          transition={reduce ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
                        >
                          <p className="px-5 pb-5 text-sm leading-relaxed text-muted-foreground sm:px-6">
                            <Highlight text={item.answer} query={q} />
                          </p>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </motion.li>
                );
              })}
            </AnimatePresence>
          </motion.ul>
        </LayoutGroup>

        <AnimatePresence>
          {results.length === 0 && (
            <motion.div
              key="empty"
              initial={{ opacity: 0, y: 12 }}
              animate={{ opacity: 1, y: 0, transition: { delay: 0.1 } }}
              exit={{ opacity: 0 }}
              className="mt-2 flex flex-col items-center rounded-3xl border border-dashed px-6 py-14 text-center"
            >
              <span className="grid size-14 place-items-center rounded-2xl bg-muted text-muted-foreground">
                <SearchX aria-hidden className="size-6" />
              </span>
              <p className="mt-4 font-semibold text-foreground">
                No results for &ldquo;{q}&rdquo;{category !== allLabel && <> in {category}</>}
              </p>
              <p className="mt-1 max-w-sm text-sm text-muted-foreground">Try a different keyword, pick another topic, or ask our team directly.</p>
              <button
                type="button"
                onClick={() => {
                  setQuery("");
                  setCategory(allLabel);
                }}
                className="mt-5 rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                Clear filters
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

More in FAQ

View all →