{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-chapters",
  "title": "Scroll Chapters",
  "description": "A draggable, chaptered progress-bar nav — two-way cursor/scroll sync, ghost-cursor hover preview, snap-to-step seeking, and a per-chapter panel that can opt in or out of showing.",
  "dependencies": ["gsap", "@gsap/react"],
  "registryDependencies": ["@soralabs/lib-scroll-trigger-utils"],
  "files": [
    {
      "path": "registry/catalog/scroll-chapters/index.tsx",
      "content": "// biome-ignore-all lint: GSAP-driven draggable scrollbar/chapter-panel primitive\n\"use client\";\n\nimport { useGSAP } from \"@gsap/react\";\nimport { cn } from \"@/lib/utils\";\nimport gsap from \"gsap\";\nimport { Draggable } from \"gsap/Draggable\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { type ReactNode, useRef, useState } from \"react\";\nimport {\n  observeWindowResize,\n  waitForScrollerReady,\n} from \"@/lib/scroll-trigger-utils\";\n\ngsap.registerPlugin(Draggable, ScrollTrigger);\n\nconst GHOST_OPACITY = 0.45;\nconst PANEL_BREAKPOINT = \"(min-width: 900px)\";\n\n/**\n * Minimal layout defaults — structural only, plus a `bg-foreground`\n * fallback on the cursor/ghost so the widget isn't invisible out of the\n * box. It's the *first* class in the string precisely so `classNames`\n * (merged in after, see `resolveScrollChaptersClasses`) can override it —\n * a fallback color appended after `classNames` in the `cn()` chain would\n * win via tailwind-merge's last-conflicting-utility-wins rule and quietly\n * clobber whatever color a consumer passed in.\n */\nconst LAYOUT = {\n  chapter: \"relative w-full\",\n  cursor:\n    \"bg-foreground absolute top-[-0.125rem] z-[5] h-4 w-[3px] cursor-grab rounded-[3px] [will-change:transform]\",\n  cursorGhost: \"pointer-events-none z-[4] opacity-0\",\n  nav: \"pointer-events-none fixed right-4 bottom-4 left-4 z-20 h-40 overflow-hidden min-[900px]:left-auto min-[900px]:right-16 min-[900px]:bottom-16 min-[900px]:h-64 min-[900px]:w-[min(21rem,calc(100%-2rem))] min-[1500px]:right-[calc((100vw-1500px+8rem)/2)] min-[900px]:[@media(min-height:64rem)]:bottom-[calc((100lvh-64rem+8rem)/2)]\",\n  panel:\n    \"pointer-events-none absolute inset-x-0 bottom-12 z-[1] w-full opacity-0 [will-change:transform,opacity]\",\n  // z-[2]: must stay above the panel (z-[1]) so the track/cursor never\n  // visually loses to it if their boxes happen to overlap — the panel is\n  // read-only, pointer-events-none, so this only affects paint order.\n  progressCard: \"absolute inset-x-0 bottom-0 z-[2] h-10 opacity-0\",\n  root: \"relative w-full\",\n  tick: \"pointer-events-none relative z-0 h-3 flex-1 opacity-0\",\n  track:\n    \"relative top-[0.865rem] left-4 flex h-3 w-[calc(100%-2rem)] cursor-pointer\",\n} as const;\n\nexport interface ScrollChaptersChapter {\n  /** In-flow section content — fully consumer-supplied markup. */\n  content: ReactNode;\n  id: string;\n  /** Omit to opt this chapter out of showing a panel entirely. */\n  preview?: ReactNode;\n}\n\nexport interface ScrollChaptersClassNames {\n  chapter?: string;\n  cursor?: string;\n  cursorGhost?: string;\n  nav?: string;\n  panel?: string;\n  progressCard?: string;\n  tick?: string;\n  track?: string;\n}\n\nexport interface ScrollChaptersProps {\n  chapters: ScrollChaptersChapter[];\n  className?: string;\n  /** Per-slot class overrides. `cn()` merges after the structural defaults. */\n  classNames?: ScrollChaptersClassNames;\n  /** Use container query height (`cqh`) instead of viewport height (`svh`) for embedded mode. @default false */\n  containerQuery?: boolean;\n  /** Catalog/docs preview — sizes chapters against `scroller` instead of the viewport. @default false */\n  embedded?: boolean;\n  /** Progress (0-1) above which the widget hides, when `hideCardAtEnd` is true. @default 0.98 */\n  endThreshold?: number;\n  /** Hide the whole widget near the very end of the content. @default true */\n  hideCardAtEnd?: boolean;\n  /** Hide the whole widget near the very start of the content. @default true */\n  hideCardAtStart?: boolean;\n  /**\n   * GSAP ScrollTrigger refresh priority. Lower numbers refresh later.\n   * @default -1\n   */\n  refreshPriority?: number;\n  /** Scroll container for ScrollTrigger. Defaults to `window`. */\n  scroller?: Element | Window;\n  /** Progress (0-1) below which the widget hides, when `hideCardAtStart` is true. @default 0.02 */\n  startThreshold?: number;\n}\n\nfunction resolveScrollChaptersClasses(\n  className: string | undefined,\n  classNames: ScrollChaptersClassNames | undefined,\n  embedded = false\n) {\n  const defaultNav = embedded\n    ? \"pointer-events-none fixed right-4 bottom-4 z-20 h-64 w-[min(21rem,calc(100%-2rem))] overflow-hidden sm:right-6 sm:bottom-6\"\n    : LAYOUT.nav;\n\n  return {\n    chapter: cn(LAYOUT.chapter, classNames?.chapter),\n    cursor: cn(LAYOUT.cursor, classNames?.cursor),\n    cursorGhost: cn(LAYOUT.cursorGhost, classNames?.cursorGhost),\n    nav: cn(defaultNav, classNames?.nav),\n    panel: cn(LAYOUT.panel, classNames?.panel),\n    progressCard: cn(LAYOUT.progressCard, classNames?.progressCard),\n    root: cn(LAYOUT.root, className),\n    tick: cn(LAYOUT.tick, classNames?.tick),\n    track: cn(LAYOUT.track, classNames?.track),\n  };\n}\n\nexport function ScrollChapters({\n  chapters,\n  className,\n  classNames,\n  containerQuery = false,\n  embedded = false,\n  endThreshold = 0.98,\n  hideCardAtEnd = true,\n  hideCardAtStart = true,\n  refreshPriority = -1,\n  scroller: scrollerProp,\n  startThreshold = 0.02,\n}: ScrollChaptersProps) {\n  const classes = resolveScrollChaptersClasses(className, classNames, embedded);\n\n  const rootRef = useRef<HTMLElement>(null);\n  const chapterRefs = useRef<(HTMLElement | null)[]>([]);\n  const navRef = useRef<HTMLDivElement>(null);\n  const progressCardRef = useRef<HTMLDivElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const cursorRef = useRef<HTMLDivElement>(null);\n  const ghostRef = useRef<HTMLDivElement>(null);\n  const tickRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  const [displayedChapter, setDisplayedChapter] = useState<\n    ScrollChaptersChapter | undefined\n  >();\n\n  const hasCards = chapters.length > 0;\n\n  useGSAP(\n    () => {\n      if (!hasCards) {\n        return;\n      }\n\n      const root = rootRef.current;\n      const nav = navRef.current;\n      const progressCard = progressCardRef.current;\n      const track = trackRef.current;\n      const cursor = cursorRef.current;\n      const ghost = ghostRef.current;\n      const panel = panelRef.current;\n\n      if (!(root && nav && progressCard && track && cursor && ghost && panel)) {\n        return;\n      }\n\n      if (embedded && scrollerProp === undefined) {\n        if (process.env.NODE_ENV !== \"production\") {\n          console.warn(\n            \"[ScrollChapters] embedded=true requires a `scroller` prop — animation will not mount without it.\"\n          );\n        }\n        return;\n      }\n\n      let disposed = false;\n      let masterTrigger: ScrollTrigger | null = null;\n      let draggable: Draggable | undefined;\n      let mm: ReturnType<typeof gsap.matchMedia> | null = null;\n      let unbindResize: (() => void) | undefined;\n      let resizeObserver: ResizeObserver | undefined;\n      const cleanupFns: (() => void)[] = [];\n\n      const mount = async () => {\n        const chapterEls = chapterRefs.current.filter(\n          (el): el is HTMLElement => el !== null\n        );\n        const ticks = tickRefs.current.filter(\n          (el): el is HTMLDivElement => el !== null\n        );\n\n        if (chapterEls.length !== chapters.length) {\n          return;\n        }\n\n        const scroller = scrollerProp ?? window;\n        await waitForScrollerReady(scroller);\n\n        if (disposed || rootRef.current !== root) {\n          return;\n        }\n\n        const snapStep = gsap.utils.snap(1 / 65);\n        const clampProgress = gsap.utils.clamp(0, 1);\n        const cursorTravel = () => track.offsetWidth - cursor.offsetWidth;\n\n        const progressFromEvent = (e: MouseEvent) => {\n          const rect = track.getBoundingClientRect();\n          const raw = (e.clientX - rect.left) / rect.width;\n          return clampProgress(snapStep(raw));\n        };\n\n        gsap.set(cursor, { x: 0 });\n        gsap.set(ghost, { x: 0, opacity: 0 });\n        gsap.set(progressCard, { opacity: 0, y: \"100%\" });\n        gsap.set(ticks, { opacity: 0 });\n        gsap.set(panel, { opacity: 0, y: \"120%\" });\n        gsap.set(nav, { pointerEvents: \"none\" });\n\n        const grabCursor = () => {\n          gsap.to(cursor, { duration: 0.25, scale: 1.25 });\n        };\n        const releaseCursor = () => {\n          gsap.to(cursor, { duration: 0.15, scale: 1 });\n        };\n\n        // Draggable re-reads the element's live transform on every `onPress`,\n        // so there's no need to resync it here — doing so mid-tween (via\n        // `.update()`) snaps the element back to Draggable's last-known\n        // position and fights the tween started below. `overwrite: \"auto\"`\n        // matters too: `true` kills *every* other tween on this target,\n        // including the reveal scale-in below — since this runs on every\n        // scroll tick, that permanently strands the cursor mid-tween.\n        const setCursorProgress = (progress: number) => {\n          gsap.to(cursor, {\n            duration: 0.2,\n            ease: \"power2.out\",\n            overwrite: \"auto\",\n            x: progress * cursorTravel(),\n          });\n        };\n\n        let widgetVisible = false;\n        // Whether the panel is currently showing *something* — tracked\n        // separately so a same-visibility transition (chapter A's panel ->\n        // chapter B's panel, both showing) can play a quick refresh flash\n        // instead of reusing the reveal/hide tween, which would be a no-op.\n        let panelVisible = false;\n        // Gated by the mobile matchMedia below — panels never show under\n        // the breakpoint, mirroring the original's mobile skip.\n        let panelsEnabled = false;\n        // The active chapter is derived from a single progress value (the\n        // same one driving the cursor), not N independent per-chapter\n        // ScrollTriggers each guessing their own \"top center\"/\"bottom\n        // center\" boundary in pixels. Two separately-rounded boundaries\n        // that are each supposed to land on the exact same scroll\n        // position (chapter i's end == chapter i+1's start, in theory)\n        // can be off by a pixel in `embedded`/`containerQuery` mode,\n        // where chapter height comes from a `cqh` calc — that 1px gap is\n        // a dead zone where *no* chapter is \"active\", which stops\n        // scrolling right on it hides the panel with nothing left to\n        // bring it back. A single division has no second number to\n        // disagree with.\n        let activeChapterIndex = -1;\n        const currentChapter = () => chapters[activeChapterIndex];\n        const chapterIndexForProgress = (progress: number) =>\n          gsap.utils.clamp(\n            0,\n            chapters.length - 1,\n            Math.floor(progress * chapters.length)\n          );\n\n        // Holds whichever tween/timeline `syncPanel` last started, so the\n        // *next* call can explicitly `.kill()` it up front. A drag\n        // gesture can cross several chapters within milliseconds — each\n        // crossing calls `syncPanel` again before the previous call's\n        // animation has finished. Relying on each `.to()`'s own\n        // `overwrite: \"auto\"` to cancel the previous call's tween isn't\n        // reliable here: GSAP's auto-overwrite scans for *other active\n        // tweens of the same target*, but tweens nested inside a\n        // `gsap.timeline()` (the flash branch below creates a fresh one\n        // every call) aren't always found and killed the same way plain\n        // `gsap.to()` calls are — in testing, a fast multi-chapter drag\n        // could leave an earlier call's `onComplete` (which sets\n        // `displayedChapter`) firing *after* a later call's, leaving the\n        // panel showing stale content indefinitely. Explicitly killing\n        // the previous animation removes any ambiguity: only the latest\n        // call's `onComplete` can ever run.\n        let panelTween: gsap.core.Timeline | gsap.core.Tween | null = null;\n\n        const syncPanel = (\n          show: boolean,\n          chapter: ScrollChaptersChapter | undefined\n        ) => {\n          const shouldShow = widgetVisible && panelsEnabled && show;\n          panelTween?.kill();\n          if (shouldShow && panelVisible) {\n            // Swap the rendered content at the trough of the flash, while\n            // the panel is faded down to opacity 0 — never while it's\n            // visibly on screen with its old height.\n            panelTween = gsap\n              .timeline()\n              .to(panel, {\n                duration: 0.15,\n                ease: \"power2.in\",\n                onComplete: () => setDisplayedChapter(chapter),\n                opacity: 0,\n                y: -6,\n              })\n              .to(panel, {\n                duration: 0.25,\n                ease: \"power3.out\",\n                opacity: 1,\n                y: 0,\n              });\n          } else if (shouldShow) {\n            // Coming from hidden: the panel has no height on screen yet\n            // (opacity 0, translated off), so it's safe to set the new\n            // content immediately, before the reveal tween runs.\n            setDisplayedChapter(chapter);\n            panelTween = gsap.to(panel, {\n              duration: 0.35,\n              ease: \"power3.inOut\",\n              opacity: 1,\n              y: 0,\n            });\n          } else {\n            panelTween = gsap.to(panel, {\n              duration: 0.25,\n              ease: \"power3.inOut\",\n              onComplete: () => setDisplayedChapter(undefined),\n              opacity: 0,\n              y: \"120%\",\n            });\n          }\n          panelVisible = shouldShow;\n        };\n\n        const syncCurrentPanel = () => {\n          const chapter = currentChapter();\n          syncPanel(Boolean(chapter?.preview), chapter);\n        };\n\n        const updateActiveChapter = (progress: number) => {\n          const index = chapterIndexForProgress(progress);\n          if (index === activeChapterIndex) {\n            return;\n          }\n          activeChapterIndex = index;\n          syncCurrentPanel();\n        };\n\n        let navHidden = true;\n        const revealNav = () => {\n          if (!navHidden) {\n            return;\n          }\n          navHidden = false;\n          widgetVisible = true;\n          gsap.set(nav, { pointerEvents: \"auto\" });\n          gsap\n            .timeline({ id: \"reveal-scroll-chapters-nav\" })\n            .to(progressCard, { duration: 0.25, opacity: 1, y: 0 })\n            .to(ticks, { duration: 0.25, opacity: 0.5, stagger: 0.02 }, \"<\")\n            .fromTo(\n              cursor,\n              { opacity: 0, scale: 0 },\n              { duration: 0.25, ease: \"back.out(2)\", opacity: 1, scale: 1 },\n              \"<+=0.05\"\n            );\n          syncCurrentPanel();\n        };\n        const hideNav = () => {\n          if (navHidden) {\n            return;\n          }\n          navHidden = true;\n          widgetVisible = false;\n          gsap.set(nav, { pointerEvents: \"none\" });\n          gsap.to(progressCard, { duration: 0.25, opacity: 0, y: \"100%\" });\n          gsap.to(ticks, { duration: 0.2, opacity: 0 });\n          syncCurrentPanel();\n        };\n        const toggleNav = (progress: number) => {\n          if (hideCardAtStart && progress < startThreshold) {\n            hideNav();\n          } else if (hideCardAtEnd && progress > endThreshold) {\n            hideNav();\n          } else {\n            revealNav();\n          }\n        };\n\n        // `fixed` nav sticks to the viewport (or a transformed ancestor). On\n        // stacked catalog mobile the demo sits above the docs column — without\n        // leave/enter handlers, `hideCardAtEnd={false}` leaves the widget\n        // painted over Installation/Usage after the chapters scroll away, and\n        // the preview's `max-lg:z-0` stacking context lets that docs content\n        // cover it (\"sunken\" ticks). Hide whenever the trigger is inactive;\n        // re-enter restores via the same threshold logic as `onUpdate`.\n        // Gate reveal on `isActive` so a trailing `onUpdate` at progress=1\n        // cannot resurrect the nav after `onLeave` when `hideCardAtEnd` is false.\n        masterTrigger = ScrollTrigger.create({\n          end: \"bottom bottom\",\n          onEnter: (self) => {\n            toggleNav(self.progress);\n          },\n          onEnterBack: (self) => {\n            toggleNav(self.progress);\n          },\n          onLeave: () => {\n            hideNav();\n          },\n          onLeaveBack: () => {\n            hideNav();\n          },\n          onUpdate: (self) => {\n            if (!draggable?.isDragging) {\n              setCursorProgress(self.progress);\n            }\n            updateActiveChapter(self.progress);\n            if (self.isActive) {\n              toggleNav(self.progress);\n            } else {\n              hideNav();\n            }\n          },\n          refreshPriority,\n          scroller,\n          start: \"top top\",\n          trigger: root,\n        });\n\n        const st = masterTrigger;\n\n        draggable = Draggable.create(cursor, {\n          activeCursor: \"grabbing\",\n          bounds: track,\n          cursor: \"grab\",\n          onDrag() {\n            const progress = clampProgress(this.x / cursorTravel());\n            st.scroll(gsap.utils.interpolate(st.start, st.end, progress));\n            // Update immediately rather than waiting for the `scroll`\n            // event this triggers to round-trip back to `st`'s own\n            // onUpdate — keeps the panel glued to the cursor while\n            // dragging instead of lagging a frame behind.\n            updateActiveChapter(progress);\n          },\n          onPress: grabCursor,\n          onRelease() {\n            releaseCursor();\n            toggleNav(clampProgress(this.x / cursorTravel()));\n          },\n          type: \"x\",\n        })[0];\n\n        const showGhost = () => {\n          gsap.to(ghost, { duration: 0.15, opacity: GHOST_OPACITY });\n        };\n        const hideGhost = () => {\n          gsap.to(ghost, { duration: 0.15, opacity: 0 });\n        };\n        const onTrackMove = (e: MouseEvent) => {\n          if (draggable?.isDragging || e.target === cursor) {\n            hideGhost();\n            return;\n          }\n          gsap.to(ghost, {\n            duration: 0.12,\n            ease: \"power2.out\",\n            opacity: GHOST_OPACITY,\n            x: progressFromEvent(e) * cursorTravel(),\n          });\n        };\n        const onTrackClick = (e: MouseEvent) => {\n          if (e.target === cursor) {\n            return;\n          }\n          st.scroll(\n            gsap.utils.interpolate(st.start, st.end, progressFromEvent(e))\n          );\n        };\n\n        track.addEventListener(\"click\", onTrackClick);\n        track.addEventListener(\"mousemove\", onTrackMove);\n        track.addEventListener(\"mouseenter\", showGhost);\n        track.addEventListener(\"mouseleave\", hideGhost);\n        cursor.addEventListener(\"mouseenter\", grabCursor);\n        cursor.addEventListener(\"mouseleave\", releaseCursor);\n        cleanupFns.push(() => {\n          track.removeEventListener(\"click\", onTrackClick);\n          track.removeEventListener(\"mousemove\", onTrackMove);\n          track.removeEventListener(\"mouseenter\", showGhost);\n          track.removeEventListener(\"mouseleave\", hideGhost);\n          cursor.removeEventListener(\"mouseenter\", grabCursor);\n          cursor.removeEventListener(\"mouseleave\", releaseCursor);\n        });\n\n        // Mirrors the original's mobile skip: panels never animate in\n        // below the breakpoint at all. When embedded (e.g. preview container),\n        // we enable panels directly because the container width is split.\n        if (embedded) {\n          panelsEnabled = true;\n          syncCurrentPanel();\n        } else {\n          mm = gsap.matchMedia();\n          mm.add(PANEL_BREAKPOINT, () => {\n            panelsEnabled = true;\n            syncCurrentPanel();\n\n            return () => {\n              panelsEnabled = false;\n              syncCurrentPanel();\n            };\n          });\n        }\n\n        if (scroller instanceof HTMLElement) {\n          resizeObserver = new ResizeObserver(() => {\n            ScrollTrigger.refresh();\n          });\n          resizeObserver.observe(scroller);\n        } else {\n          unbindResize = observeWindowResize(() => {\n            ScrollTrigger.refresh();\n          });\n        }\n\n        ScrollTrigger.refresh();\n      };\n\n      void mount();\n\n      return () => {\n        disposed = true;\n        resizeObserver?.disconnect();\n        unbindResize?.();\n        mm?.revert();\n        masterTrigger?.kill();\n        draggable?.kill();\n        for (const cleanup of cleanupFns) {\n          cleanup();\n        }\n      };\n    },\n    {\n      dependencies: [\n        chapters,\n        embedded,\n        scrollerProp,\n        refreshPriority,\n        hasCards,\n        hideCardAtStart,\n        hideCardAtEnd,\n        startThreshold,\n        endThreshold,\n      ],\n      scope: rootRef,\n    }\n  );\n\n  return (\n    <section className={classes.root} ref={rootRef}>\n      {chapters.map((chapter, index) => (\n        <article\n          className={classes.chapter}\n          data-chapter={chapter.id}\n          key={chapter.id}\n          ref={(el) => {\n            chapterRefs.current[index] = el;\n          }}\n          style={containerQuery ? { height: \"100cqh\" } : undefined}\n        >\n          {chapter.content}\n        </article>\n      ))}\n\n      <div className={classes.nav} ref={navRef}>\n        <div className={classes.progressCard} ref={progressCardRef}>\n          <div className={classes.track} ref={trackRef}>\n            {chapters.map((chapter, index) => (\n              <div\n                aria-hidden=\"true\"\n                className={cn(\n                  classes.tick,\n                  \"border-l\",\n                  index === chapters.length - 1 && \"border-r\"\n                )}\n                key={chapter.id}\n                ref={(el) => {\n                  tickRefs.current[index] = el;\n                }}\n              />\n            ))}\n            <div className={classes.cursor} ref={cursorRef} />\n            <div\n              aria-hidden=\"true\"\n              className={cn(classes.cursor, classes.cursorGhost)}\n              ref={ghostRef}\n            />\n          </div>\n        </div>\n\n        <div\n          className={classes.panel}\n          data-card={displayedChapter?.id}\n          ref={panelRef}\n        >\n          {displayedChapter?.preview}\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/sora-ui/catalog/scroll-chapters.tsx"
    }
  ],
  "meta": {
    "keywords": [
      "scrollbar",
      "scroll",
      "chapters",
      "draggable",
      "progress",
      "nav",
      "gsap",
      "scrolltrigger"
    ],
    "inspiration": {
      "type": "reimplemented",
      "label": "Anime.js",
      "stack": "GSAP and vanilla JS"
    }
  },
  "type": "registry:ui"
}
