{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"faq-section","type":"registry:block","title":"FAQ Section","description":"FAQ page section with an eyebrow, heading and description column beside an accordion that opens one or many answers with height-morphing panels and a rotating plus, an optional search that narrows questions with spring layout reflow and highlights matches, a Still have questions CTA slot, optional hash deep links, and a layout that splits into two columns based on its own container width.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/faq-section.tsx","type":"registry:component","target":"@components/motion/faq-section.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/faq-section\n\nimport { Plus, Search, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FaqItem {\n  /** Stable id. Also the element id / URL hash when `deepLink` is on. */\n  id: string;\n  question: string;\n  answer: ReactNode;\n  /**\n   * Plain-text version of `answer` used for search when `answer` is not a\n   * string. String answers are searched (and highlighted) directly.\n   */\n  searchText?: string;\n}\n\nexport interface FaqSectionProps {\n  items: FaqItem[];\n  eyebrow?: ReactNode;\n  heading?: ReactNode;\n  description?: ReactNode;\n  /** \"single\" keeps one answer open at a time; \"multiple\" lets several stay open. */\n  type?: \"single\" | \"multiple\";\n  /** Controlled open item ids. */\n  value?: string[];\n  /** Uncontrolled initial open item ids. */\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Show a search field that narrows and highlights questions. */\n  searchable?: boolean;\n  searchPlaceholder?: string;\n  /** Rendered when the search matches nothing. Receives the query. */\n  emptyState?: (query: string) => ReactNode;\n  /** \"Still have questions?\" slot under the header column. */\n  cta?: ReactNode;\n  /**\n   * Give each item `id={item.id}`, open the item named by `location.hash`\n   * (on mount and on hashchange) and write the hash when an item opens.\n   */\n  deepLink?: boolean;\n  className?: string;\n}\n\nconst PANEL_TRANSITION: Transition = { duration: 0.32, ease: EASE_OUT };\nconst PANEL_EXIT_TRANSITION: Transition = { duration: 0.22, ease: EASE_OUT };\n\nfunction escapeRegExp(value: string) {\n  return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/** Wrap every case-insensitive occurrence of `query` in a <mark>. */\nfunction highlight(text: string, query: string): ReactNode {\n  const q = query.trim();\n  if (!q) return text;\n  const parts = text.split(new RegExp(`(${escapeRegExp(q)})`, \"gi\"));\n  const lower = q.toLowerCase();\n  let offset = 0;\n  return parts.map((part) => {\n    const key = `${offset}-${part}`;\n    offset += part.length;\n    return part.toLowerCase() === lower ? (\n      <mark\n        key={key}\n        className=\"rounded-[4px] bg-primary/15 px-0.5 text-foreground\"\n      >\n        {part}\n      </mark>\n    ) : (\n      part\n    );\n  });\n}\n\nfunction matches(item: FaqItem, query: string) {\n  const q = query.trim().toLowerCase();\n  if (!q) return true;\n  const answerText =\n    typeof item.answer === \"string\" ? item.answer : (item.searchText ?? \"\");\n  return (\n    item.question.toLowerCase().includes(q) ||\n    answerText.toLowerCase().includes(q)\n  );\n}\n\n/**\n * FAQ page section: a header column (eyebrow, heading, description, optional\n * search and a CTA slot) beside an accordion of questions. Searching narrows\n * the list with layout animation and highlights matches; items can be\n * deep-linked by hash. Columns sit side by side once the section's own\n * container is wide enough and stack below that.\n */\nexport function FaqSection({\n  items,\n  eyebrow = \"FAQ\",\n  heading = \"Frequently asked questions\",\n  description,\n  type = \"single\",\n  value,\n  defaultValue = [],\n  onValueChange,\n  searchable = false,\n  searchPlaceholder = \"Search questions\",\n  emptyState,\n  cta,\n  deepLink = false,\n  className,\n}: FaqSectionProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const [internal, setInternal] = useState<string[]>(defaultValue);\n  const open = value ?? internal;\n  const [query, setQuery] = useState(\"\");\n\n  const setOpen = useCallback(\n    (next: string[]) => {\n      if (value === undefined) setInternal(next);\n      onValueChange?.(next);\n    },\n    [value, onValueChange],\n  );\n\n  const toggle = useCallback(\n    (id: string) => {\n      const isOpen = open.includes(id);\n      const next = isOpen\n        ? open.filter((x) => x !== id)\n        : type === \"single\"\n          ? [id]\n          : [...open, id];\n      setOpen(next);\n      if (deepLink && !isOpen && typeof window !== \"undefined\") {\n        window.history.replaceState(null, \"\", `#${id}`);\n      }\n    },\n    [open, type, setOpen, deepLink],\n  );\n\n  // Latest state for the hash listener, read through a ref so the listener\n  // subscribes once and never re-scrolls to the hash item on every toggle.\n  const latest = useRef({ open, type, setOpen, reduce });\n  latest.current = { open, type, setOpen, reduce };\n\n  // Deep links: read the hash after mount (never during render) and follow\n  // later hash changes.\n  useEffect(() => {\n    if (!deepLink) return;\n    const openFromHash = () => {\n      const id = decodeURIComponent(window.location.hash.slice(1));\n      if (!id || !items.some((item) => item.id === id)) return;\n      const { open: current, type: mode, setOpen: set, reduce: still } =\n        latest.current;\n      if (!current.includes(id)) {\n        set(mode === \"single\" ? [id] : [...current, id]);\n      }\n      document.getElementById(id)?.scrollIntoView({\n        block: \"nearest\",\n        behavior: still ? \"auto\" : \"smooth\",\n      });\n    };\n    openFromHash();\n    window.addEventListener(\"hashchange\", openFromHash);\n    return () => window.removeEventListener(\"hashchange\", openFromHash);\n  }, [deepLink, items]);\n\n  const filtered = useMemo(\n    () => items.filter((item) => matches(item, query)),\n    [items, query],\n  );\n\n  const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\n  return (\n    <section\n      className={cn(\n        \"@container w-full text-foreground\",\n        className,\n      )}\n    >\n      <div className=\"grid gap-8 @xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @xl:gap-10\">\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"flex flex-col gap-3\">\n            {eyebrow ? (\n              <p className=\"text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground\">\n                {eyebrow}\n              </p>\n            ) : null}\n            {heading ? (\n              <h2 className=\"text-balance text-2xl font-semibold leading-tight tracking-tight @3xl:text-3xl\">\n                {heading}\n              </h2>\n            ) : null}\n            {description ? (\n              <p className=\"text-pretty text-sm leading-6 text-muted-foreground\">\n                {description}\n              </p>\n            ) : null}\n          </div>\n\n          {searchable ? (\n            <div className=\"relative\">\n              <label htmlFor={`${baseId}-search`} className=\"sr-only\">\n                {searchPlaceholder}\n              </label>\n              <Search\n                aria-hidden\n                className=\"pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted-foreground\"\n              />\n              <input\n                id={`${baseId}-search`}\n                type=\"search\"\n                value={query}\n                onChange={(event) => setQuery(event.target.value)}\n                placeholder={searchPlaceholder}\n                autoComplete=\"off\"\n                className=\"h-10 w-full rounded-full border border-border bg-card pr-10 pl-10 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-search-cancel-button]:appearance-none\"\n              />\n              <AnimatePresence initial={false}>\n                {query ? (\n                  <motion.button\n                    key=\"clear\"\n                    type=\"button\"\n                    aria-label=\"Clear search\"\n                    onClick={() => setQuery(\"\")}\n                    initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                    transition={SPRING_SWAP}\n                    className=\"absolute top-1/2 right-2 grid size-7 -translate-y-1/2 place-items-center rounded-full text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40\"\n                  >\n                    <X className=\"size-3.5\" />\n                  </motion.button>\n                ) : null}\n              </AnimatePresence>\n              <p aria-live=\"polite\" className=\"sr-only\">\n                {query\n                  ? `${filtered.length} of ${items.length} questions shown`\n                  : \"\"}\n              </p>\n            </div>\n          ) : null}\n\n          {cta ? <div className=\"hidden @xl:block\">{cta}</div> : null}\n        </div>\n\n        <div className=\"flex flex-col gap-4\">\n          {/* layoutRoot keeps the rows' layout animation measured relative to\n              this list, so page scroll never replays as movement. */}\n          <motion.div layoutRoot className=\"relative flex flex-col gap-2\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {filtered.map((item) => (\n                <FaqRow\n                  key={item.id}\n                  item={item}\n                  baseId={baseId}\n                  open={open.includes(item.id)}\n                  query={query}\n                  deepLink={deepLink}\n                  reduce={reduce}\n                  layoutTransition={layoutTransition}\n                  onToggle={() => toggle(item.id)}\n                />\n              ))}\n              {filtered.length === 0 ? (\n                <motion.div\n                  key=\"empty\"\n                  layout\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 0.2, ease: EASE_OUT }}\n                  className=\"rounded-2xl border border-dashed border-border-strong px-5 py-8 text-center text-sm text-muted-foreground\"\n                >\n                  {emptyState ? (\n                    emptyState(query)\n                  ) : (\n                    <>\n                      No questions match &ldquo;{query.trim()}&rdquo;.{\" \"}\n                      <button\n                        type=\"button\"\n                        onClick={() => setQuery(\"\")}\n                        className=\"font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-2 focus-visible:ring-ring/40\"\n                      >\n                        Clear search\n                      </button>\n                    </>\n                  )}\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n          </motion.div>\n\n          {cta ? <div className=\"@xl:hidden\">{cta}</div> : null}\n        </div>\n      </div>\n    </section>\n  );\n}\n\nfunction FaqRow({\n  item,\n  baseId,\n  open,\n  query,\n  deepLink,\n  reduce,\n  layoutTransition,\n  onToggle,\n}: {\n  item: FaqItem;\n  baseId: string;\n  open: boolean;\n  query: string;\n  deepLink: boolean;\n  reduce: boolean | null;\n  layoutTransition: Transition;\n  onToggle: () => void;\n}) {\n  const triggerId = `${baseId}-${item.id}-trigger`;\n  const panelId = `${baseId}-${item.id}-panel`;\n\n  return (\n    // Layout animation is the point here: rows glide into the gaps left by\n    // filtered-out questions and by answers opening/closing.\n    <motion.div\n      layout=\"position\"\n      id={deepLink ? item.id : undefined}\n      initial={{ opacity: 0, scale: reduce ? 1 : 0.97 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={{\n        opacity: 0,\n        scale: reduce ? 1 : 0.97,\n        transition: { duration: 0.15, ease: EASE_OUT },\n      }}\n      transition={{ layout: layoutTransition, duration: 0.22, ease: EASE_OUT }}\n      data-state={open ? \"open\" : \"closed\"}\n      className={cn(\n        \"scroll-mt-6 overflow-hidden rounded-2xl border bg-card text-card-foreground transition-colors\",\n        open ? \"border-border-strong\" : \"border-border\",\n      )}\n    >\n      <h3 className=\"m-0\">\n        <button\n          id={triggerId}\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={panelId}\n          onClick={onToggle}\n          className=\"flex min-h-[52px] w-full items-center gap-4 px-5 py-3 text-left text-[15px] font-medium text-foreground outline-none transition-colors hover:bg-muted/40 focus-visible:bg-muted/40\"\n        >\n          <span className=\"min-w-0 flex-1 text-pretty\">\n            {highlight(item.question, query)}\n          </span>\n          <motion.span\n            aria-hidden\n            initial={false}\n            animate={{ rotate: open ? 45 : 0 }}\n            transition={reduce ? { duration: 0 } : SPRING_SWAP}\n            className={cn(\n              \"grid size-6 shrink-0 place-items-center rounded-full transition-colors\",\n              open\n                ? \"bg-primary text-primary-foreground\"\n                : \"bg-muted text-muted-foreground\",\n            )}\n          >\n            <Plus className=\"size-3.5\" />\n          </motion.span>\n        </button>\n      </h3>\n      {/* Height is animated on purpose: the accordion's job is to disclose\n          content in flow, which has to push the following rows down. */}\n      <motion.div\n        id={panelId}\n        role=\"region\"\n        aria-labelledby={triggerId}\n        inert={!open}\n        initial={false}\n        animate={{ height: open ? \"auto\" : 0, opacity: open ? 1 : 0 }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : open\n              ? PANEL_TRANSITION\n              : PANEL_EXIT_TRANSITION\n        }\n        className=\"overflow-hidden\"\n      >\n        <div className=\"px-5 pb-4 text-sm leading-6 text-muted-foreground\">\n          {typeof item.answer === \"string\"\n            ? highlight(item.answer, query)\n            : item.answer}\n        </div>\n      </motion.div>\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}