{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"faq-accordion","type":"registry:component","title":"FAQ accordion","description":"Questions on hairlines that open like they mean it: the answer eases to its height, the plus folds into a minus.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/faq-accordion.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FAQ ACCORDION: questions that open like they mean it\n *\n *   closed   a question on a hairline, with a plus\n *   opening  the answer eases open to its real height while its\n *            text settles in out of a slight blur; the plus\n *            turns and folds into a minus\n *   closing  the answer fades first, then the space closes\n *   single   opening one closes the other in the same motion\n *\n * Up and Down move between questions, Home and End jump.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FaqItem {\n  question: string;\n  answer: React.ReactNode;\n  id?: string;\n}\n\nexport interface FaqAccordionProps extends React.HTMLAttributes<HTMLDivElement> {\n  items: FaqItem[];\n  /** One answer open at a time, or any number. */\n  type?: \"single\" | \"multiple\";\n  /** Questions open at first, by index. */\n  defaultOpen?: number[];\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction PlusMinus({ open, reduced }: { open: boolean; reduced: boolean }) {\n  const t = reduced ? \"none\" : `transform 420ms ${EASE}, opacity 300ms ${EASE}`;\n  return (\n    <span aria-hidden=\"true\" className=\"relative flex size-4 shrink-0 items-center justify-center\" style={{ transform: open ? \"rotate(180deg)\" : \"none\", transition: t }}>\n      <span className=\"absolute h-px w-3 rounded-full bg-current\" />\n      {/* The upright folds flat to make the minus. */}\n      <span className=\"absolute h-3 w-px rounded-full bg-current\" style={{ transform: open ? \"rotate(90deg) scaleY(0.4)\" : \"none\", opacity: open ? 0 : 1, transition: t }} />\n    </span>\n  );\n}\n\nexport function FaqAccordion({ items, type = \"single\", defaultOpen = [], className = \"\", ...props }: FaqAccordionProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [open, setOpen] = React.useState<Set<number>>(() => new Set(type === \"single\" ? defaultOpen.slice(0, 1) : defaultOpen));\n  const buttons = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const toggle = (i: number) =>\n    setOpen((prev) => {\n      const next = new Set(type === \"single\" ? [] : prev);\n      if (prev.has(i)) next.delete(i);\n      else next.add(i);\n      return next;\n    });\n\n  const onKeyDown = (e: React.KeyboardEvent, i: number) => {\n    const moves: Record<string, number> = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: items.length - 1 };\n    if (!(e.key in moves)) return;\n    e.preventDefault();\n    buttons.current[(moves[e.key] + items.length) % items.length]?.focus();\n  };\n\n  return (\n    <div className={`w-full border-b border-border ${className}`} {...props}>\n      {items.map((item, i) => {\n        const isOpen = open.has(i);\n        const key = item.id ?? `${id}-${i}`;\n        return (\n          <div key={key} className=\"border-t border-border\">\n            <h3>\n              <button\n                ref={(el) => {\n                  buttons.current[i] = el;\n                }}\n                type=\"button\"\n                id={`${key}-q`}\n                aria-expanded={isOpen}\n                aria-controls={`${key}-a`}\n                onClick={() => toggle(i)}\n                onKeyDown={(e) => onKeyDown(e, i)}\n                className={`group flex w-full items-center justify-between gap-6 rounded-lg py-4 text-left text-[15px] outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40 ${\n                  isOpen ? \"text-foreground\" : \"text-foreground/80 hover:text-foreground\"\n                }`}\n              >\n                <span className=\"font-medium tracking-[-0.01em]\">{item.question}</span>\n                <span className={`transition-colors duration-300 ${isOpen ? \"text-foreground\" : \"text-muted-foreground group-hover:text-foreground\"}`}>\n                  <PlusMinus open={isOpen} reduced={reduced} />\n                </span>\n              </button>\n            </h3>\n            <div\n              id={`${key}-a`}\n              role=\"region\"\n              aria-labelledby={`${key}-q`}\n              inert={!isOpen}\n              className=\"grid\"\n              style={{ gridTemplateRows: isOpen ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 460ms ${EASE}` }}\n            >\n              <div className=\"min-h-0 overflow-hidden\">\n                <div\n                  className=\"pb-5 pr-10 text-[14px] leading-relaxed text-muted-foreground\"\n                  style={{\n                    opacity: isOpen ? 1 : 0,\n                    filter: isOpen ? \"none\" : \"blur(4px)\",\n                    transform: isOpen ? \"none\" : \"translateY(-6px)\",\n                    transition: reduced\n                      ? \"none\"\n                      : isOpen\n                        ? `opacity 420ms ${EASE} 90ms, filter 420ms ${EASE} 90ms, transform 520ms ${EASE} 60ms`\n                        : `opacity 160ms ease-out, filter 160ms ease-out, transform 240ms ease-out`,\n                  }}\n                >\n                  {item.answer}\n                </div>\n              </div>\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/faq-accordion"}