{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "faq-answer-depth-gutter",
  "title": "FAQ Accordion",
  "description": "A plain FAQ accordion: real button semantics, one answer open at a time, a smooth height transition and a thin accent rail marking the open question.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/faq-answer-depth-gutter/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ReactNode,\n  type TransitionEvent as ReactTransitionEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// FaqDepthGutter — a plain FAQ accordion.\n//\n// Real button semantics (Tab/Enter/Space are native, no keydown handler),\n// aria-expanded on the trigger, aria-controls -> a role=region panel labelled\n// by the trigger. Opening eases the panel 0 -> scrollHeight, then pins it to\n// height:auto on transitionend so later reflow stays correct; closing pins\n// auto back to px, forces a reflow and eases to 0, and `hidden` goes on only\n// after that ends, so a screen reader never sees a half-collapsed region.\n// A 2px --ns-accent rail marks the open row. prefers-reduced-motion toggles\n// height with no transition. Tokens only, zero dependencies.\n// ---------------------------------------------------------------------------\n\nconst OPEN_MS = 260;\nconst CLOSE_MS = 200;\nconst EASE = \"cubic-bezier(0.22,1,0.36,1)\";\n\nexport interface FaqDepthItem {\n  id: string;\n  question: string;\n  answer: ReactNode;\n}\n\nexport interface FaqDepthGutterProps {\n  /** the question/answer pairs, in order */\n  items: FaqDepthItem[];\n  /** Allow more than one answer open at once. Default false (single-open). */\n  allowMultiple?: boolean;\n  /** Item ids open on first render. */\n  defaultOpen?: string[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nfunction prefersReduced() {\n  return (\n    typeof window !== \"undefined\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n\nexport function FaqDepthGutter({\n  items,\n  allowMultiple = false,\n  defaultOpen = [],\n  className = \"\",\n}: FaqDepthGutterProps) {\n  const baseId = useId().replace(/:/g, \"\");\n  const [openIds, setOpenIds] = useState<Set<string>>(() => new Set(defaultOpen));\n\n  const panelRefs = useRef<Map<string, HTMLDivElement>>(new Map());\n  const initialized = useRef<Set<string>>(new Set());\n  const prevOpen = useRef<Set<string>>(new Set(defaultOpen));\n  const openRef = useRef(openIds);\n\n  openRef.current = openIds;\n\n  const expand = useCallback((id: string) => {\n    const el = panelRefs.current.get(id);\n    if (!el) return;\n    el.hidden = false;\n    el.style.transition = \"none\";\n    el.style.height = \"0px\";\n    void el.offsetHeight; // forced reflow: commit the start height\n    if (prefersReduced()) {\n      el.style.transition = \"\";\n      el.style.height = \"auto\";\n      return;\n    }\n    el.style.transition = `height ${OPEN_MS}ms ${EASE}`;\n    el.style.height = `${el.scrollHeight}px`;\n  }, []);\n\n  const collapse = useCallback((id: string) => {\n    const el = panelRefs.current.get(id);\n    if (!el) return;\n    el.style.transition = \"none\";\n    el.style.height = `${el.offsetHeight}px`; // pin height:auto back to px\n    void el.offsetHeight; // forced reflow before easing down\n    if (prefersReduced()) {\n      el.style.transition = \"\";\n      el.style.height = \"0px\";\n      el.hidden = true;\n      return;\n    }\n    el.style.transition = `height ${CLOSE_MS}ms ${EASE}`;\n    el.style.height = \"0px\";\n  }, []);\n\n  useEffect(() => {\n    openIds.forEach((id) => {\n      if (!prevOpen.current.has(id)) expand(id);\n    });\n    prevOpen.current.forEach((id) => {\n      if (!openIds.has(id)) collapse(id);\n    });\n    prevOpen.current = new Set(openIds);\n  }, [openIds, expand, collapse]);\n\n  const onPanelTransitionEnd = (e: ReactTransitionEvent<HTMLDivElement>, id: string) => {\n    if (e.propertyName !== \"height\" || e.target !== e.currentTarget) return;\n    const el = panelRefs.current.get(id);\n    if (!el) return;\n    el.style.transition = \"\";\n    if (openRef.current.has(id)) {\n      // auto, not px: later reflow or a text-wrap change stays correct\n      el.style.height = \"auto\";\n    } else {\n      el.style.height = \"0px\";\n      el.hidden = true;\n    }\n  };\n\n  const toggle = (id: string) => {\n    setOpenIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) {\n        next.delete(id);\n      } else {\n        if (!allowMultiple) next.clear();\n        next.add(id);\n      }\n      return next;\n    });\n  };\n\n  return (\n    <div className={`ns-fdg-root ${className}`.trim()}>\n      <style>{CSS}</style>\n\n      {items.map((item) => {\n        const isOpen = openIds.has(item.id);\n        const triggerId = `${baseId}-q-${item.id}`;\n        const panelId = `${baseId}-a-${item.id}`;\n\n        return (\n          <div key={item.id} className=\"ns-fdg-row\" data-open={isOpen}>\n            <h3 className=\"ns-fdg-h3\">\n              <button\n                type=\"button\"\n                id={triggerId}\n                data-faq-trigger=\"\"\n                className=\"ns-fdg-trigger\"\n                aria-expanded={isOpen}\n                aria-controls={panelId}\n                onClick={() => toggle(item.id)}\n              >\n                <span className=\"ns-fdg-question\">{item.question}</span>\n                <span className=\"ns-fdg-sign\" data-open={isOpen} aria-hidden=\"true\">\n                  <span className=\"ns-fdg-sign-h\" />\n                  <span className=\"ns-fdg-sign-v\" />\n                </span>\n              </button>\n            </h3>\n\n            <div\n              id={panelId}\n              ref={(el) => {\n                if (!el) {\n                  panelRefs.current.delete(item.id);\n                  return;\n                }\n                panelRefs.current.set(item.id, el);\n                if (initialized.current.has(item.id)) return;\n                initialized.current.add(item.id);\n                if (openIds.has(item.id)) {\n                  el.style.height = \"auto\";\n                } else {\n                  el.style.height = \"0px\";\n                  el.hidden = true;\n                }\n              }}\n              data-faq-panel=\"\"\n              data-open={isOpen}\n              role=\"region\"\n              aria-labelledby={triggerId}\n              className=\"ns-fdg-panel\"\n              onTransitionEnd={(e) => onPanelTransitionEnd(e, item.id)}\n            >\n              <div className=\"ns-fdg-answer\">{item.answer}</div>\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-fdg-root{width:100%;color:var(--foreground);padding-left:14px;}\n.ns-fdg-root *,.ns-fdg-root *::before,.ns-fdg-root *::after{box-sizing:border-box;}\n\n.ns-fdg-row{position:relative;}\n.ns-fdg-row + .ns-fdg-row{border-top:1px solid var(--border);}\n/* Open-row rail: fixed height, so two open rows never join into one bar. */\n.ns-fdg-row::before{\n  content:\"\";position:absolute;left:-14px;top:18px;width:2px;height:18px;\n  border-radius:9999px;background-color:transparent;\n  transition:background-color 160ms ease;\n}\n.ns-fdg-row[data-open=\"true\"]::before{background-color:var(--ns-accent);}\n\n.ns-fdg-h3{margin:0;font-size:inherit;font-weight:inherit;}\n.ns-fdg-trigger{\n  display:flex;width:100%;align-items:center;justify-content:space-between;gap:20px;\n  padding:16px 0;border:0;background:none;\n  color:var(--foreground);font:inherit;font-size:15px;line-height:1.4;\n  text-align:left;cursor:pointer;border-radius:6px;\n}\n.ns-fdg-trigger:hover .ns-fdg-question{color:var(--ns-accent);}\n.ns-fdg-trigger:focus-visible{outline:2px solid var(--ns-accent);outline-offset:3px;}\n.ns-fdg-question{min-width:0;transition:color 160ms ease;}\n\n.ns-fdg-sign{position:relative;flex:0 0 10px;width:10px;height:10px;}\n.ns-fdg-sign-h,.ns-fdg-sign-v{position:absolute;left:0;top:0;background-color:var(--ns-muted);}\n.ns-fdg-sign-h{width:10px;height:1.5px;top:4.25px;}\n.ns-fdg-sign-v{width:1.5px;height:10px;left:4.25px;transition:transform 200ms ${EASE};transform-origin:center;}\n.ns-fdg-sign[data-open=\"true\"] .ns-fdg-sign-v{transform:scaleY(0);}\n\n.ns-fdg-panel{overflow:hidden;height:0;}\n.ns-fdg-panel[hidden]{display:none;}\n.ns-fdg-answer{padding:0 0 20px 0;color:var(--ns-muted);font-size:13.5px;line-height:1.65;}\n.ns-fdg-answer p{margin:0;}\n.ns-fdg-answer p + p{margin-top:11px;}\n\n@media (prefers-reduced-motion: reduce){\n  .ns-fdg-sign-v,.ns-fdg-question{transition-duration:0ms !important;}\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/faq-answer-depth-gutter.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "faq",
      "accordion",
      "disclosure",
      "section",
      "marketing"
    ],
    "instruction": "Build <FaqDepthGutter items allowMultiple? defaultOpen? className?> where items is {id, question, answer: ReactNode}[]. STRUCTURE: each item is a row containing a real <button data-faq-trigger aria-expanded aria-controls={panelId}> inside an <h3>, plus a <div data-faq-panel data-open role=\"region\" aria-labelledby={triggerId}> panel holding the answer. Rows are separated by a single 1px --border rule on `.row + .row`, and every rule shares one left edge — nothing is inset per state. OPEN/CLOSE: opening sets the panel hidden=false, height:0, forces a reflow (void el.offsetHeight) and eases height to el.scrollHeight over OPEN_MS 260ms cubic-bezier(0.22,1,0.36,1); on transitionend the height is set to auto so later reflow or a text-wrap change stays correct. Closing pins auto back to the measured px, forces a reflow, eases to 0 over CLOSE_MS 200ms, and applies `hidden` only after that transition ends so a screen reader never sees a half-collapsed region. allowMultiple defaults to false: opening one closes the previous. INDICATOR: the open row carries a 2px x 18px --ns-accent rail in the root's left padding, absolutely positioned at a fixed offset so adjacent open rows always render as two discrete marks, never one continuous bar. No measurement, no offscreen mirror, no ResizeObserver, no per-item bar chart. ACCESSIBILITY: real button semantics, so Tab/Enter/Space are native and there is no keydown handler; aria-expanded on the trigger, aria-controls to the panel id, aria-labelledby back to the trigger; data-open exposed on both row and panel for probing; a visible :focus-visible outline in --ns-accent at 3px offset. REDUCED MOTION: prefers-reduced-motion (checked via matchMedia at toggle time) toggles the panel between 0 and auto with no transition, so nothing can be left stuck mid-animation. TOKENS ONLY: --border for the row rules, --foreground for questions, --ns-muted for answers and the plus/minus sign, --ns-accent for the open-row rail, the :focus-visible ring and the hovered question; both themes come out of the same variables. REST STATE: the demo mounts with one answer open, so the idle frame reads unmistakably as an FAQ. Zero dependencies."
  },
  "type": "registry:ui"
}