Skip to content

Searchable FAQ Accordion

An FAQ with live search that highlights matches, topic chips with a sliding indicator and counts, height-animated answers, a plus-to-minus icon morph, deep-link anchors with a copy-link button, a designed empty state and a full keyboard model.

DOM + CSS0 deps1 file · 32 KB TSXreduced-motion aware

Freeminimalglass
Preview

Props

Allow several open
Topic chips
No runtime dependencies
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import type {
  CSSProperties,
  KeyboardEvent as ReactKeyboardEvent,
  PointerEvent as ReactPointerEvent,
} from "react";

export type Faq = { q: string; a: string; category?: string };

export type SearchableFaqAccordionProps = {
  /** Question and answer pairs. Add a `category` to any entry to get topic chips. */
  faqs?: Faq[];
  searchPlaceholder?: string;
  /** Panel surface: "dark" (default) or "light". */
  tone?: "dark" | "light";
  /** Hex colour for highlights, chips and the focus ring. */
  accent?: string;
  /** Indexes (into faqs) that start open. */
  defaultOpen?: number[];
  /** false = opening one answer closes the others. */
  allowMultiple?: boolean;
  /** Read #anchors on load, show a copy-link button in each answer. */
  deepLink?: boolean;
  /** Write the open answer's anchor to the URL hash when it opens (default off). */
  syncHash?: boolean;
  /** Prefix for answer anchors, e.g. #faq-how-do-i-install-it. */
  idPrefix?: string;
  /** Show the topic chips when the entries carry categories. */
  showCategories?: boolean;
  /** Replaces the default width class (max-w-md). */
  className?: string;
};

const DEFAULT_FAQS: Faq[] = [
  {
    q: "How do I install it?",
    a: "Copy the file into your project and import it. There is no package to add and no build step beyond what React and Tailwind already give you.",
    category: "Setup",
  },
  {
    q: "Does it work with Next.js?",
    a: "Yes. It is a client component with no browser-only code at module scope, so it renders on the server and hydrates cleanly.",
    category: "Setup",
  },
  {
    q: "Can I change the colors?",
    a: "Pass an accent color and choose a dark or light tone. The surface paints itself, so it does not depend on your theme tokens.",
    category: "Style",
  },
  {
    q: "How do I add my own questions?",
    a: "Give it an array of question and answer pairs. Add a category to any entry and the topic chips appear on their own.",
    category: "Style",
  },
  {
    q: "Is it keyboard friendly?",
    a: "Every question is a real button. Arrow keys move between questions, slash jumps to the search field and Escape clears it. Motion switches off when the system asks for less of it.",
    category: "Usage",
  },
  {
    q: "Can I link to one answer?",
    a: "Yes. Each answer has its own anchor, so a link opens the page with that answer already expanded. Use the copy link button inside any open answer.",
    category: "Usage",
  },
];

const NO_OPEN: number[] = [];
const ACCENT_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const ACCENT_DEFAULT = { dark: "#8aa0ff", light: "#3347d4" };

const slugify = (s: string) =>
  s
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 48);
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

const CSS = `
.sfa{container-type:inline-size;--sfa-ring:var(--sfa-accent);font-family:inherit;text-align:left;-webkit-font-smoothing:antialiased}
.sfa[data-tone="dark"]{--sfa-bg:#0b0c10;--sfa-text:#f2f3f6;--sfa-muted:rgba(232,236,246,.62);--sfa-faint:rgba(232,236,246,.38);--sfa-line:rgba(255,255,255,.08);--sfa-hover:rgba(255,255,255,.035);--sfa-open:rgba(255,255,255,.03);--sfa-field:rgba(255,255,255,.05);--sfa-shadow:0 1px 0 rgba(255,255,255,.07) inset,0 0 0 1px rgba(255,255,255,.07),0 28px 70px -28px rgba(0,0,0,.85)}
.sfa[data-tone="light"]{--sfa-bg:#ffffff;--sfa-text:#0f1222;--sfa-muted:rgba(15,18,34,.64);--sfa-faint:rgba(15,18,34,.42);--sfa-line:rgba(15,18,34,.09);--sfa-hover:rgba(15,18,34,.03);--sfa-open:rgba(15,18,34,.02);--sfa-field:rgba(15,18,34,.04);--sfa-shadow:0 0 0 1px rgba(15,18,34,.08),0 1px 2px rgba(15,18,34,.05),0 24px 56px -30px rgba(15,18,34,.28)}
.sfa *{box-sizing:border-box}
.sfa-surface{position:relative;isolation:isolate;overflow:hidden;border-radius:20px;background:var(--sfa-bg);color:var(--sfa-text);box-shadow:var(--sfa-shadow)}
.sfa-glow{position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:0;transition:opacity .5s ease;background:radial-gradient(380px circle at var(--sfa-x,50%) var(--sfa-y,0%),color-mix(in srgb,var(--sfa-accent) 15%,transparent),transparent 72%)}
@media (hover:hover){.sfa-surface:hover .sfa-glow{opacity:1}}
.sfa-head{padding:14px 14px 10px}
.sfa-field{display:flex;align-items:center;gap:10px;height:48px;padding:0 10px 0 14px;border-radius:14px;background:var(--sfa-field);box-shadow:inset 0 0 0 1px var(--sfa-line);transition:box-shadow .25s ease}
.sfa-field:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--sfa-accent) 65%,transparent),0 0 0 4px color-mix(in srgb,var(--sfa-accent) 16%,transparent)}
.sfa-si{flex:none;width:16px;height:16px;color:var(--sfa-faint);transition:color .25s ease}
.sfa-field:focus-within .sfa-si{color:var(--sfa-accent)}
.sfa .sfa-input{flex:1;min-width:0;height:100%;padding:0;margin:0;border:0;background:transparent;color:var(--sfa-text);font:inherit;font-size:15px;letter-spacing:-.005em;outline:none!important;box-shadow:none;-webkit-appearance:none;appearance:none;border-radius:0!important}
.sfa .sfa-input::placeholder{color:var(--sfa-faint);opacity:1}
.sfa .sfa-input::-webkit-search-cancel-button,.sfa .sfa-input::-webkit-search-decoration{-webkit-appearance:none;display:none}
.sfa-kbd{flex:none;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--sfa-faint);padding:5px 7px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--sfa-line);transition:opacity .2s ease}
.sfa-field:focus-within .sfa-kbd{opacity:0}
.sfa-x{flex:none;display:grid;place-items:center;width:28px;height:28px;padding:0;border:0;border-radius:999px;background:transparent;color:var(--sfa-muted);cursor:pointer;transition:background .2s ease,color .2s ease}
.sfa-x:hover{background:var(--sfa-line);color:var(--sfa-text)}
.sfa-x svg{width:14px;height:14px}
.sfa-chipwrap{padding:0 14px 8px}
.sfa-chips{position:relative;display:flex;gap:6px;padding:2px;margin:-2px;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
.sfa-chips::-webkit-scrollbar{display:none}
.sfa-ind{position:absolute;top:2px;left:0;height:32px;width:0;border-radius:999px;pointer-events:none;opacity:0;background:color-mix(in srgb,var(--sfa-accent) 16%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--sfa-accent) 45%,transparent)}
.sfa-ind[data-ready]{transition:transform .42s cubic-bezier(.32,.72,0,1),width .42s cubic-bezier(.32,.72,0,1),opacity .2s ease}
.sfa-chip{position:relative;z-index:1;flex:none;display:inline-flex;align-items:center;height:32px;padding:0 13px;border:0;border-radius:999px;background:transparent;color:var(--sfa-muted);font:inherit;font-size:12.5px;font-weight:500;letter-spacing:.005em;cursor:pointer;box-shadow:inset 0 0 0 1px var(--sfa-line);transition:color .2s ease,background .2s ease,transform .2s ease;-webkit-tap-highlight-color:transparent}
.sfa-chip:hover{color:var(--sfa-text);background:var(--sfa-hover)}
.sfa-chip:active{transform:scale(.96)}
.sfa-chip[data-active="true"]{color:var(--sfa-text);box-shadow:none;background:transparent}
.sfa-chip[data-dim="true"]{opacity:.45}
.sfa-n{margin-left:7px;font-size:11px;font-variant-numeric:tabular-nums;opacity:.6}
.sfa .sfa-chip:focus-visible,.sfa .sfa-link:focus-visible,.sfa .sfa-x:focus-visible,.sfa .sfa-copy:focus-visible,.sfa .sfa-empty-btn:focus-visible{outline:2px solid var(--sfa-ring)!important;outline-offset:2px!important;border-radius:999px}
.sfa-meta{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 18px 10px;min-height:34px}
.sfa-count{margin:0;font-size:12px;color:var(--sfa-faint);font-variant-numeric:tabular-nums}
.sfa-link{padding:4px 2px;border:0;background:transparent;color:var(--sfa-muted);font:inherit;font-size:12px;cursor:pointer;transition:color .2s ease}
.sfa-link:hover{color:var(--sfa-accent)}
.sfa-list{border-top:1px solid var(--sfa-line)}
.sfa-item{position:relative;animation:sfa-in .44s cubic-bezier(.22,1,.36,1) both;animation-delay:calc(var(--i,0)*34ms)}
.sfa-item+.sfa-item{border-top:1px solid var(--sfa-line)}
.sfa-item::before{content:"";position:absolute;left:0;top:12px;bottom:12px;width:2px;border-radius:2px;background:var(--sfa-accent);box-shadow:0 0 14px var(--sfa-accent);transform:scaleY(0);transform-origin:50% 50%;transition:transform .42s cubic-bezier(.32,.72,0,1)}
.sfa-item[data-open="true"]::before{transform:scaleY(1)}
.sfa-item[data-open="true"]{background:var(--sfa-open)}
@keyframes sfa-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.sfa-h{margin:0;font:inherit}
.sfa-trigger{display:flex;align-items:center;gap:14px;width:100%;min-height:58px;padding:14px 16px 14px 18px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;transition:background .2s ease;-webkit-tap-highlight-color:transparent}
.sfa-trigger:hover{background:var(--sfa-hover)}
.sfa .sfa-trigger:focus-visible{outline:2px solid var(--sfa-ring)!important;outline-offset:-2px!important;border-radius:0!important;background:var(--sfa-hover)}
.sfa-q{flex:1;min-width:0;font-size:15px;font-weight:550;line-height:1.35;letter-spacing:-.01em;color:var(--sfa-text)}
.sfa-tag{flex:none;display:none;font-size:10.5px;font-weight:500;letter-spacing:.09em;text-transform:uppercase;color:var(--sfa-faint)}
.sfa-tag[data-hit="true"]{display:inline-flex;align-items:center;gap:6px;color:var(--sfa-accent);letter-spacing:.06em}
.sfa-tag[data-hit="true"]::before{content:"";width:5px;height:5px;border-radius:99px;background:var(--sfa-accent)}
@container (min-width:520px){.sfa-tag{display:inline-flex}}
.sfa-icon{position:relative;flex:none;width:28px;height:28px;border-radius:999px;box-shadow:inset 0 0 0 1px var(--sfa-line);color:var(--sfa-muted);transition:transform .5s cubic-bezier(.34,1.4,.64,1),background .3s ease,color .3s ease,box-shadow .3s ease}
.sfa-icon i{position:absolute;left:50%;top:50%;width:11px;height:1.5px;margin:0;border-radius:2px;background:currentColor;transform:translate(-50%,-50%);transition:transform .5s cubic-bezier(.34,1.4,.64,1)}
.sfa-icon i+i{transform:translate(-50%,-50%) rotate(90deg)}
.sfa-trigger:hover .sfa-icon{color:var(--sfa-text)}
.sfa-item[data-open="true"] .sfa-icon{transform:rotate(180deg);color:var(--sfa-accent);background:color-mix(in srgb,var(--sfa-accent) 14%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--sfa-accent) 50%,transparent)}
.sfa-item[data-open="true"] .sfa-icon i+i{transform:translate(-50%,-50%) rotate(0deg)}
.sfa-panel{display:grid;grid-template-rows:0fr;visibility:hidden;transition:grid-template-rows .4s cubic-bezier(.32,.72,0,1),visibility 0s linear .4s}
.sfa-item[data-open="true"] .sfa-panel{grid-template-rows:1fr;visibility:visible;transition:grid-template-rows .4s cubic-bezier(.32,.72,0,1),visibility 0s}
.sfa-clip{min-height:0;overflow:hidden}
.sfa-inner{padding:0 62px 18px 18px;opacity:0;transform:translateY(-6px);transition:opacity .2s ease,transform .3s cubic-bezier(.32,.72,0,1)}
.sfa-item[data-open="true"] .sfa-inner{opacity:1;transform:none;transition:opacity .35s ease .08s,transform .45s cubic-bezier(.22,1,.36,1) .06s}
.sfa-a{margin:0;max-width:62ch;font-size:14.5px;line-height:1.65;color:var(--sfa-muted)}
.sfa-mark{padding:0 2px;margin:0 -1px;border-radius:4px;background:color-mix(in srgb,var(--sfa-accent) 28%,transparent);color:inherit;box-shadow:0 0 0 1px color-mix(in srgb,var(--sfa-accent) 22%,transparent);animation:sfa-mark .36s cubic-bezier(.22,1,.36,1) both}
.sfa-q .sfa-mark{color:var(--sfa-text)}
@keyframes sfa-mark{from{background-color:color-mix(in srgb,var(--sfa-accent) 60%,transparent)}}
.sfa-copy{display:inline-flex;align-items:center;gap:7px;height:32px;margin-top:12px;padding:0 12px;border:0;border-radius:999px;background:var(--sfa-field);box-shadow:inset 0 0 0 1px var(--sfa-line);color:var(--sfa-muted);font:inherit;font-size:12px;font-weight:500;cursor:pointer;transition:color .2s ease,background .2s ease,box-shadow .2s ease,transform .2s ease;-webkit-tap-highlight-color:transparent}
.sfa-copy:hover{color:var(--sfa-text)}
.sfa-copy:active{transform:scale(.96)}
.sfa-copy[data-done="true"]{color:var(--sfa-accent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--sfa-accent) 50%,transparent)}
.sfa-copy svg{width:13px;height:13px}
.sfa-empty{display:flex;flex-direction:column;align-items:center;padding:34px 24px 38px;text-align:center;animation:sfa-in .5s cubic-bezier(.22,1,.36,1) both}
.sfa-art{width:84px;height:84px;margin-bottom:16px;color:var(--sfa-faint)}
.sfa-art .sfa-orbit{transform-origin:42px 42px;animation:sfa-spin 28s linear infinite}
.sfa-art .sfa-lens{animation:sfa-bob 4.2s ease-in-out infinite}
@keyframes sfa-spin{to{transform:rotate(360deg)}}
@keyframes sfa-bob{50%{transform:translate(2px,-3px)}}
.sfa-empty-t{margin:0;font-size:15px;font-weight:550;letter-spacing:-.01em;color:var(--sfa-text);overflow-wrap:anywhere}
.sfa-empty-s{margin:6px 0 0;max-width:34ch;font-size:13.5px;line-height:1.55;color:var(--sfa-muted)}
.sfa-empty-row{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:18px}
.sfa-empty-btn{height:34px;padding:0 14px;border:0;border-radius:999px;background:var(--sfa-field);box-shadow:inset 0 0 0 1px var(--sfa-line);color:var(--sfa-text);font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;transition:background .2s ease,box-shadow .2s ease,transform .2s ease}
.sfa-empty-btn:hover{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--sfa-accent) 55%,transparent)}
.sfa-empty-btn:active{transform:scale(.96)}
.sfa-empty-btn[data-primary="true"]{background:color-mix(in srgb,var(--sfa-accent) 18%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--sfa-accent) 50%,transparent)}
@media (hover:none),(pointer:coarse){
.sfa-kbd{display:none}
.sfa .sfa-input{font-size:16px}
.sfa-field{height:52px}
.sfa-chip{height:40px}
.sfa-ind{height:40px}
.sfa-x{width:40px;height:40px}
.sfa-link{min-height:40px;padding:0 4px}
.sfa-copy{height:40px}
.sfa-empty-btn{height:40px}
.sfa-trigger{min-height:62px}
}
@container (max-width:380px){
.sfa-q{font-size:14.5px}
.sfa-trigger{gap:10px;padding-left:14px;padding-right:12px}
.sfa-inner{padding:0 14px 16px 14px}
.sfa-head{padding:10px 10px 8px}
.sfa-chipwrap{padding:0 10px 6px}
.sfa-meta{padding:4px 14px 8px}
}
@media (prefers-reduced-motion:reduce){
.sfa *,.sfa *::before,.sfa *::after{transition-duration:.01ms!important;transition-delay:0s!important;animation-duration:.01ms!important;animation-delay:0s!important;animation-iteration-count:1!important}
.sfa-item[data-open="true"] .sfa-icon{transform:none}
.sfa-item[data-open="true"] .sfa-icon i+i{transform:translate(-50%,-50%) rotate(90deg) scaleX(0)}
.sfa-inner{transform:none!important}
}
`;

function Hl({ text, re }: { text: string; re: RegExp | null }) {
  if (!re) return <>{text}</>;
  const parts = text.split(re);
  return (
    <>
      {parts.map((p, i) =>
        i % 2 === 1 ? (
          <mark key={i} className="sfa-mark">
            {p}
          </mark>
        ) : (
          p
        ),
      )}
    </>
  );
}

export function SearchableFaqAccordion({
  faqs = DEFAULT_FAQS,
  searchPlaceholder = "Search questions",
  tone = "dark",
  accent,
  defaultOpen = NO_OPEN,
  allowMultiple = true,
  deepLink = true,
  syncHash = false,
  idPrefix = "faq",
  showCategories = true,
  className,
}: SearchableFaqAccordionProps) {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const rootRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const chipsRef = useRef<HTMLDivElement>(null);
  const indRef = useRef<HTMLSpanElement>(null);
  const copyTimer = useRef(0);
  const itemsRef = useRef<{ id: number; anchor: string }[]>([]);

  const [query, setQuery] = useState("");
  const [cat, setCat] = useState<string | null>(null);
  const [open, setOpen] = useState<ReadonlySet<number>>(
    () => new Set(defaultOpen.filter((i) => i >= 0 && i < faqs.length)),
  );
  const [copied, setCopied] = useState<number | null>(null);
  const [announce, setAnnounce] = useState("");

  const items = useMemo(() => {
    const seen = new Map<string, number>();
    return faqs.map((f, i) => {
      let s = slugify(f.q) || "question";
      const n = seen.get(s) ?? 0;
      seen.set(s, n + 1);
      if (n > 0) s = s + "-" + (n + 1);
      return {
        ...f,
        id: i,
        anchor: idPrefix + "-" + s,
        hay: (f.q + " " + f.a + " " + (f.category ?? "")).toLowerCase(),
        qLow: f.q.toLowerCase(),
      };
    });
  }, [faqs, idPrefix]);

  const categories = useMemo(() => {
    const out: string[] = [];
    for (const it of items) if (it.category && !out.includes(it.category)) out.push(it.category);
    return out;
  }, [items]);
  const hasChips = showCategories && categories.length > 1;
  const activeCat = cat !== null && categories.includes(cat) ? cat : null;

  const terms = useMemo(
    () => Array.from(new Set(query.trim().toLowerCase().split(/\s+/).filter(Boolean))),
    [query],
  );
  const re = useMemo(
    () =>
      terms.length
        ? new RegExp("(" + [...terms].sort((a, b) => b.length - a.length).map(escapeRe).join("|") + ")", "gi")
        : null,
    [terms],
  );
  const textMatches = useMemo(() => items.filter((it) => terms.every((t) => it.hay.includes(t))), [items, terms]);
  const visible = activeCat === null ? textMatches : textMatches.filter((it) => it.category === activeCat);
  const catCount = (c: string) => textMatches.filter((it) => it.category === c).length;

  const chipSig =
    String(activeCat) + "|" + categories.map((c) => c + ":" + textMatches.filter((it) => it.category === c).length).join("|") + "|" + textMatches.length + "|" + hasChips;

  const accentColor = accent && ACCENT_RE.test(accent) ? accent : ACCENT_DEFAULT[tone === "light" ? "light" : "dark"];
  const vars = { "--sfa-accent": accentColor } as CSSProperties;

  useEffect(() => {
    itemsRef.current = items;
  }, [items]);

  useEffect(
    () => () => {
      window.clearTimeout(copyTimer.current);
    },
    [],
  );

  // Deep links: open the answer named by the URL hash, now and on hashchange.
  useEffect(() => {
    if (!deepLink) return;
    const apply = () => {
      let h = "";
      try {
        h = decodeURIComponent(window.location.hash.slice(1));
      } catch {
        return;
      }
      const it = itemsRef.current.find((x) => x.anchor === h);
      if (!it) return;
      setQuery("");
      setCat(null);
      setOpen((prev) => (allowMultiple ? new Set(prev).add(it.id) : new Set([it.id])));
      requestAnimationFrame(() => {
        const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        document.getElementById(it.anchor)?.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
      });
    };
    const raf = requestAnimationFrame(apply);
    window.addEventListener("hashchange", apply);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("hashchange", apply);
    };
  }, [deepLink, allowMultiple]);

  // Sliding chip indicator, placed in layout pixels.
  useEffect(() => {
    const wrap = chipsRef.current;
    const ind = indRef.current;
    if (!wrap || !ind || !hasChips) return;
    const place = () => {
      const btn = wrap.querySelector<HTMLElement>('[data-active="true"]');
      if (!btn) {
        ind.style.opacity = "0";
        return;
      }
      ind.style.opacity = "1";
      ind.style.width = btn.offsetWidth + "px";
      ind.style.transform = "translateX(" + btn.offsetLeft + "px)";
    };
    place();
    const raf = requestAnimationFrame(() => {
      ind.setAttribute("data-ready", "");
      const btn = wrap.querySelector<HTMLElement>('[data-active="true"]');
      if (btn && wrap.scrollWidth > wrap.clientWidth + 1) {
        const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        wrap.scrollTo({ left: btn.offsetLeft - (wrap.clientWidth - btn.offsetWidth) / 2, behavior: reduce ? "auto" : "smooth" });
      }
    });
    const ro = new ResizeObserver(place);
    ro.observe(wrap);
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
    };
  }, [chipSig, hasChips]);

  const toggle = (id: number, anchor: string) => {
    const willOpen = !open.has(id);
    setOpen((prev) => {
      if (!allowMultiple) return prev.has(id) ? new Set<number>() : new Set([id]);
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
    if (deepLink && syncHash) {
      try {
        const base = window.location.pathname + window.location.search;
        window.history.replaceState(null, "", willOpen ? base + "#" + anchor : base);
      } catch {
        /* history can be unavailable in sandboxed frames */
      }
    }
  };

  const allOpen = visible.length > 0 && visible.every((it) => open.has(it.id));
  const toggleAll = () => {
    setOpen((prev) => {
      const next = new Set(prev);
      for (const it of visible) {
        if (allOpen) next.delete(it.id);
        else next.add(it.id);
      }
      return next;
    });
  };

  const copyLink = async (id: number, anchor: string) => {
    const url = window.location.origin + window.location.pathname + window.location.search + "#" + anchor;
    try {
      await navigator.clipboard.writeText(url);
      setCopied(id);
      setAnnounce("Link copied");
    } catch {
      setCopied(id);
      setAnnounce("Link: " + url);
    }
    window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => {
      setCopied(null);
      setAnnounce("");
    }, 1800);
  };

  const onSurfaceMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (e.pointerType === "touch") return;
    const el = e.currentTarget;
    const r = el.getBoundingClientRect();
    const k = r.width ? el.offsetWidth / r.width : 1;
    el.style.setProperty("--sfa-x", ((e.clientX - r.left) * k).toFixed(1) + "px");
    el.style.setProperty("--sfa-y", ((e.clientY - r.top) * k).toFixed(1) + "px");
  };

  const onRootKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
    const t = e.target as HTMLElement;
    if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return;
    e.preventDefault();
    inputRef.current?.focus();
    inputRef.current?.select();
  };

  const onListKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const t = e.target as HTMLElement;
    if (!t.hasAttribute("data-sfa-trigger")) return;
    if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
    const list = Array.from(rootRef.current?.querySelectorAll<HTMLElement>("[data-sfa-trigger]") ?? []);
    const i = list.indexOf(t);
    e.preventDefault();
    if (e.key === "ArrowUp" && i <= 0) {
      inputRef.current?.focus();
      return;
    }
    const n = e.key === "ArrowDown" ? i + 1 : e.key === "ArrowUp" ? i - 1 : e.key === "Home" ? 0 : list.length - 1;
    list[Math.max(0, Math.min(n, list.length - 1))]?.focus();
  };

  const onInputKey = (e: ReactKeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Escape") {
      if (query) {
        e.preventDefault();
        setQuery("");
      } else {
        e.currentTarget.blur();
      }
    } else if (e.key === "ArrowDown") {
      const first = rootRef.current?.querySelector<HTMLElement>("[data-sfa-trigger]");
      if (first) {
        e.preventDefault();
        first.focus();
      }
    }
  };

  const clearAll = () => {
    setQuery("");
    setCat(null);
    inputRef.current?.focus();
  };

  const total = items.length;
  const status =
    visible.length === total
      ? total + (total === 1 ? " question" : " questions")
      : visible.length + " of " + total + (total === 1 ? " question" : " questions");
  const shown = query.trim().length > 30 ? query.trim().slice(0, 30) + "…" : query.trim();

  return (
    <div
      ref={rootRef}
      className={"sfa relative w-full " + (className ?? "max-w-md")}
      data-tone={tone === "light" ? "light" : "dark"}
      style={vars}
      onKeyDown={onRootKey}
    >
      <style>{CSS}</style>
      <div className="sfa-surface" onPointerMove={onSurfaceMove}>
        <div className="sfa-glow" aria-hidden="true" />

        <div className="sfa-head" role="search">
          <div className="sfa-field">
            <svg className="sfa-si" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true">
              <circle cx="7" cy="7" r="4.6" />
              <path d="M10.6 10.6 14 14" />
            </svg>
            <input
              ref={inputRef}
              className="sfa-input"
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={onInputKey}
              placeholder={searchPlaceholder}
              aria-label="Search questions"
              aria-controls={uid + "-list"}
              autoComplete="off"
              autoCorrect="off"
              spellCheck={false}
              enterKeyHint="search"
            />
            {query ? (
              <button type="button" className="sfa-x" aria-label="Clear search" onClick={() => { setQuery(""); inputRef.current?.focus(); }}>
                <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true">
                  <path d="m3.5 3.5 7 7m0-7-7 7" />
                </svg>
              </button>
            ) : (
              <span className="sfa-kbd" aria-hidden="true">/</span>
            )}
          </div>
        </div>

        {hasChips && (
          <div className="sfa-chipwrap" role="group" aria-label="Filter by topic">
            <div className="sfa-chips" ref={chipsRef}>
              <span className="sfa-ind" ref={indRef} aria-hidden="true" />
              {["All", ...categories].map((c, i) => {
                const isAll = i === 0;
                const active = isAll ? activeCat === null : activeCat === c;
                const n = isAll ? textMatches.length : catCount(c);
                return (
                  <button
                    key={c}
                    type="button"
                    className="sfa-chip"
                    aria-pressed={active}
                    data-active={active}
                    data-dim={n === 0 && !active}
                    onClick={() => setCat(isAll ? null : c)}
                  >
                    {c}
                    <span className="sfa-n">{n}</span>
                  </button>
                );
              })}
            </div>
          </div>
        )}

        <div className="sfa-meta">
          <p className="sfa-count" aria-live="polite">{total === 0 ? "No questions" : status}</p>
          {allowMultiple && visible.length > 1 && (
            <button type="button" className="sfa-link" onClick={toggleAll}>
              {allOpen ? "Collapse all" : "Expand all"}
            </button>
          )}
        </div>

        <div className="sfa-list" id={uid + "-list"} onKeyDown={onListKey}>
          {visible.length === 0 ? (
            <div className="sfa-empty" role="status">
              <svg className="sfa-art" viewBox="0 0 84 84" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
                <circle className="sfa-orbit" cx="42" cy="42" r="37" strokeDasharray="2 7" opacity=".7" />
                <path d="M16 30h14M16 42h9M16 54h12" opacity=".35" />
                <g className="sfa-lens">
                  <circle cx="44" cy="40" r="14" style={{ stroke: "var(--sfa-accent)" }} strokeWidth="2" />
                  <path d="m54.5 50.5 9 9" style={{ stroke: "var(--sfa-accent)" }} strokeWidth="2.4" />
                  <path d="M38.5 40h11" />
                </g>
              </svg>
              {total === 0 ? (
                <>
                  <p className="sfa-empty-t">No questions yet</p>
                  <p className="sfa-empty-s">Pass an array of question and answer pairs to fill this list.</p>
                </>
              ) : (
                <>
                  <p className="sfa-empty-t">{shown ? "Nothing matches “" + shown + "”" : "Nothing in this topic"}</p>
                  <p className="sfa-empty-s">Check the spelling, try fewer words, or browse a topic instead.</p>
                  <div className="sfa-empty-row">
                    <button type="button" className="sfa-empty-btn" data-primary="true" onClick={clearAll}>
                      Clear search
                    </button>
                    {categories.slice(0, 3).map((c) => (
                      <button key={c} type="button" className="sfa-empty-btn" onClick={() => { setQuery(""); setCat(c); }}>
                        {c}
                      </button>
                    ))}
                  </div>
                </>
              )}
            </div>
          ) : (
            visible.map((it, idx) => {
              const isOpen = open.has(it.id);
              const panelId = uid + "-panel-" + it.id;
              const triggerId = uid + "-trigger-" + it.id;
              const answerOnly = terms.length > 0 && !terms.every((t) => it.qLow.includes(t));
              return (
                <div
                  key={it.id}
                  id={deepLink ? it.anchor : undefined}
                  className="sfa-item"
                  data-open={isOpen}
                  style={{ "--i": Math.min(idx, 7) } as CSSProperties}
                >
                  <div role="heading" aria-level={3} className="sfa-h">
                    <button
                      type="button"
                      id={triggerId}
                      className="sfa-trigger"
                      data-sfa-trigger=""
                      aria-expanded={isOpen}
                      aria-controls={panelId}
                      onClick={() => toggle(it.id, it.anchor)}
                    >
                      <span className="sfa-q">
                        <Hl text={it.q} re={re} />
                      </span>
                      {answerOnly ? (
                        <span className="sfa-tag" data-hit="true">in answer</span>
                      ) : showCategories && it.category ? (
                        <span className="sfa-tag">{it.category}</span>
                      ) : null}
                      <span className="sfa-icon" aria-hidden="true">
                        <i />
                        <i />
                      </span>
                    </button>
                  </div>
                  <div id={panelId} role="region" aria-labelledby={triggerId} className="sfa-panel">
                    <div className="sfa-clip">
                      <div className="sfa-inner">
                        <p className="sfa-a">
                          <Hl text={it.a} re={re} />
                        </p>
                        {deepLink && (
                          <button
                            type="button"
                            className="sfa-copy"
                            data-done={copied === it.id}
                            tabIndex={isOpen ? 0 : -1}
                            onClick={() => copyLink(it.id, it.anchor)}
                          >
                            {copied === it.id ? (
                              <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                                <path d="m2.8 7.4 2.7 2.7 5.7-6" />
                              </svg>
                            ) : (
                              <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                                <path d="M5.8 8.2a2.6 2.6 0 0 0 3.7 0l2-2a2.6 2.6 0 0 0-3.7-3.7l-.6.6" />
                                <path d="M8.2 5.8a2.6 2.6 0 0 0-3.7 0l-2 2a2.6 2.6 0 0 0 3.7 3.7l.6-.6" />
                              </svg>
                            )}
                            {copied === it.id ? "Link copied" : "Copy link"}
                          </button>
                        )}
                      </div>
                    </div>
                  </div>
                </div>
              );
            })
          )}
        </div>
      </div>
      <span className="sr-only" role="status" aria-live="polite">{announce}</span>
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

A searchable FAQ in one panel. Typing splits the query into words and keeps every entry whose question, answer or category contains all of them, and the matching text is marked with an accent highlight. Entries whose match is only inside the answer carry a small “in answer” tag, so a closed row still explains why it survived. When entries have a category, topic chips appear with live counts and a single indicator that glides between them. Answers open by animating a grid row from zero to its natural height with an eased curve, so no measuring is needed, while the plus icon rotates a half turn and settles into a minus. Open state is a set of each entry's original index, which means filtering changes what is visible and never what an index refers to. Every answer has an anchor such as #faq-how-do-i-install-it: loading a URL with that hash opens and scrolls to it, with the syncHash prop opening an answer also writes the hash (off by default), and a copy-link button puts the full URL on the clipboard. The empty state offers a clear button and topic shortcuts. A soft light follows the pointer across the panel. The component paints its own surface in a dark or light tone and takes any hex accent.

Curator’s note

Rebuilt in October with no dependencies: topic chips, highlighted matches and deep links. Open state is still keyed by each question's stable original index, never its position in the filtered list, so typing never reassigns which answer is open.

Pairs well with