{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-spy","type":"registry:ui","title":"Scroll Spy","description":"The section you are actually in.","dependencies":["motion"],"categories":["scroll"],"docs":"https://www.interior.dev/docs/scroll-spy","files":[{"path":"registry/interior/scroll-spy.tsx","type":"registry:ui","target":"components/interior/scroll-spy.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst SETTLE = 420;\nconst RELEASE = 900;\n\nexport type ScrollSpySection = {\n  id: string;\n  label: string;\n};\n\nexport type UseScrollSpyOptions = {\n  sections: ScrollSpySection[];\n  offset?: number;\n  root?: React.RefObject<HTMLElement | null>;\n  onChange?: (id: string) => void;\n};\n\nexport function useScrollSpy({\n  sections,\n  offset = 96,\n  root,\n  onChange,\n}: UseScrollSpyOptions) {\n  const reduced = useReducedMotion();\n\n  const [activeId, setActiveId] = useState(() => sections[0]?.id ?? \"\");\n  const [announce, setAnnounce] = useState(\"\");\n\n  const list = useRef(sections);\n  list.current = sections;\n  const emit = useRef(onChange);\n  emit.current = onChange;\n\n  const frame = useRef(0);\n  const lock = useRef<string | null>(null);\n  const lockTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const started = useRef(false);\n\n  const key = sections.map((s) => s.id).join(\"|\");\n\n  const measure = useCallback(() => {\n    const items = list.current;\n    if (items.length === 0) return \"\";\n\n    const container = root?.current ?? null;\n\n    const viewport = container ? container.clientHeight : window.innerHeight;\n    const top = container ? container.scrollTop : window.scrollY;\n    const max = container\n      ? container.scrollHeight - container.clientHeight\n      : document.documentElement.scrollHeight - window.innerHeight;\n    const ratio = max > 0 ? Math.min(1, Math.max(0, top / max)) : 1;\n\n    const line =\n      (container ? container.getBoundingClientRect().top : 0) +\n      offset +\n      ratio * Math.max(0, viewport - offset - 1);\n\n    let current = \"\";\n    let last = \"\";\n\n    for (const item of items) {\n      const node = document.getElementById(item.id);\n      if (!node) continue;\n      last = item.id;\n      if (!current) current = item.id;\n      if (node.getBoundingClientRect().top <= line + 1) current = item.id;\n    }\n\n    const atEnd = container\n      ? container.scrollTop + container.clientHeight >= container.scrollHeight - 2\n      : window.scrollY + window.innerHeight >=\n        document.documentElement.scrollHeight - 2;\n\n    return atEnd && last ? last : current;\n  }, [offset, root]);\n\n  const release = useCallback(() => {\n    lock.current = null;\n    if (lockTimer.current) {\n      clearTimeout(lockTimer.current);\n      lockTimer.current = null;\n    }\n  }, []);\n\n  const sync = useCallback(() => {\n    if (frame.current) return;\n    frame.current = requestAnimationFrame(() => {\n      frame.current = 0;\n      const next = measure();\n      if (!next) return;\n      if (lock.current) {\n        if (lock.current === next) release();\n        return;\n      }\n      setActiveId((prev) => (prev === next ? prev : next));\n    });\n  }, [measure, release]);\n\n  useEffect(() => {\n    const container = root?.current ?? null;\n    const scroller: EventTarget = container ?? window;\n    const abandon = () => {\n      if (lock.current) release();\n    };\n\n    scroller.addEventListener(\"scroll\", sync, { passive: true });\n    window.addEventListener(\"resize\", sync);\n    window.addEventListener(\"wheel\", abandon, { passive: true });\n    window.addEventListener(\"touchstart\", abandon, { passive: true });\n\n    const observer = new ResizeObserver(sync);\n    observer.observe(container ?? document.documentElement);\n    for (const id of key ? key.split(\"|\") : []) {\n      const node = document.getElementById(id);\n      if (node) observer.observe(node);\n    }\n\n    sync();\n\n    return () => {\n      scroller.removeEventListener(\"scroll\", sync);\n      window.removeEventListener(\"resize\", sync);\n      window.removeEventListener(\"wheel\", abandon);\n      window.removeEventListener(\"touchstart\", abandon);\n      observer.disconnect();\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n      if (lockTimer.current) clearTimeout(lockTimer.current);\n    };\n  }, [sync, release, key, root]);\n\n  useEffect(() => {\n    if (!activeId) return;\n    emit.current?.(activeId);\n\n    if (!started.current) {\n      started.current = true;\n      return;\n    }\n\n    settleTimer.current = setTimeout(() => {\n      const item = list.current.find((s) => s.id === activeId);\n      setAnnounce(item ? item.label : \"\");\n    }, SETTLE);\n\n    return () => {\n      if (settleTimer.current) clearTimeout(settleTimer.current);\n    };\n  }, [activeId]);\n\n  const scrollTo = useCallback(\n    (id: string) => {\n      const node = document.getElementById(id);\n      if (!node) return;\n\n      lock.current = id;\n      setActiveId(id);\n\n      const container = root?.current ?? null;\n      const behavior: ScrollBehavior = reduced ? \"auto\" : \"smooth\";\n      const rect = node.getBoundingClientRect();\n\n      const viewport = container ? container.clientHeight : window.innerHeight;\n      const max = container\n        ? Math.max(0, container.scrollHeight - container.clientHeight)\n        : Math.max(\n            0,\n            document.documentElement.scrollHeight - window.innerHeight,\n          );\n      const H = container\n        ? rect.top -\n          container.getBoundingClientRect().top +\n          container.scrollTop\n        : rect.top + window.scrollY;\n      const usable = Math.max(0, viewport - offset - 1);\n      const top =\n        max > 0\n          ? Math.min(max, Math.max(0, (H - offset) / (1 + usable / max)))\n          : 0;\n\n      if (container) container.scrollTo({ top, behavior });\n      else window.scrollTo({ top, behavior });\n\n      if (!node.hasAttribute(\"tabindex\")) node.setAttribute(\"tabindex\", \"-1\");\n      node.focus({ preventScroll: true });\n\n      if (lockTimer.current) clearTimeout(lockTimer.current);\n      lockTimer.current = setTimeout(() => {\n        lock.current = null;\n        lockTimer.current = null;\n        sync();\n      }, RELEASE);\n    },\n    [offset, reduced, root, sync],\n  );\n\n  const getLinkProps = useCallback(\n    (id: string) => ({\n      href: `#${id}`,\n      \"aria-current\": id === activeId ? (\"location\" as const) : undefined,\n      onClick: (e: React.MouseEvent<HTMLAnchorElement>) => {\n        if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n        e.preventDefault();\n        scrollTo(id);\n      },\n    }),\n    [activeId, scrollTo],\n  );\n\n  const activeIndex = sections.findIndex((s) => s.id === activeId);\n\n  return { activeId, activeIndex, scrollTo, getLinkProps, announce };\n}\n\nexport type ScrollSpyProps = {\n  sections: ScrollSpySection[];\n  offset?: number;\n  root?: React.RefObject<HTMLElement | null>;\n  onChange?: (id: string) => void;\n  label?: string;\n  className?: string;\n};\n\nexport function ScrollSpy({\n  sections,\n  offset = 96,\n  root,\n  onChange,\n  label = \"On this page\",\n  className = \"\",\n}: ScrollSpyProps) {\n  const { activeId, getLinkProps, announce } = useScrollSpy({\n    sections,\n    offset,\n    root,\n    onChange,\n  });\n  const reduced = useReducedMotion();\n  const thumbId = useId();\n  const chips = useRef(new Map<string, HTMLElement>());\n\n  useEffect(() => {\n    chips.current.get(activeId)?.scrollIntoView({\n      behavior: reduced ? \"auto\" : \"smooth\",\n      block: \"nearest\",\n      inline: \"nearest\",\n    });\n  }, [activeId, reduced]);\n\n  return (\n    <nav aria-label={label} className={`w-full ${className}`}>\n      <div className=\"rounded-[10px] bg-stone-100/80 p-1 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\">\n        <ol className=\"flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n          {sections.map((section) => {\n            const active = section.id === activeId;\n            return (\n              <li\n                key={section.id}\n                ref={(el) => {\n                  if (el) chips.current.set(section.id, el);\n                  else chips.current.delete(section.id);\n                }}\n                className=\"relative flex-[1_0_auto]\"\n              >\n                {active ? (\n                  <motion.span\n                    layoutId={reduced ? undefined : thumbId}\n                    aria-hidden\n                    transition={CELL}\n                    className=\"absolute inset-0 rounded-[6px] bg-stone-800 dark:bg-stone-100\"\n                  />\n                ) : null}\n\n                <a\n                  {...getLinkProps(section.id)}\n                  className=\"group relative flex h-7 w-full items-center justify-center rounded-[6px] px-2.5 text-[12.5px] outline-none after:pointer-events-none after:absolute after:inset-0 after:rounded-[6px] focus-visible:after:bg-[#4568FF]/[0.06] focus-visible:after:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:after:bg-[#93B0FF]/[0.1] dark:focus-visible:after:shadow-[inset_0_0_0_1px_#93B0FF]\"\n                >\n                  <span className=\"relative grid\">\n                    <span\n                      aria-hidden\n                      className=\"invisible col-start-1 row-start-1 whitespace-nowrap font-medium\"\n                    >\n                      {section.label}\n                    </span>\n                    <span\n                      className={`col-start-1 row-start-1 whitespace-nowrap transition-colors duration-150 ${\n                        active\n                          ? \"font-medium text-white dark:text-stone-900\"\n                          : \"text-stone-500 group-hover:text-stone-700 dark:text-stone-400 dark:group-hover:text-stone-200\"\n                      }`}\n                    >\n                      {section.label}\n                    </span>\n                  </span>\n                </a>\n              </li>\n            );\n          })}\n        </ol>\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </nav>\n  );\n}\n"}]}