{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-story",
  "type": "registry:ui",
  "title": "Scroll story",
  "description": "Binds local scroll progress to chapters that progressively reshape a product scene.",
  "dependencies": [
    "gsap"
  ],
  "categories": [
    "cards-media"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/scroll-story/",
  "meta": {
    "engines": [
      "gsap"
    ],
    "runtimeCost": "medium",
    "signature": "Chaptered product scene driven by ScrollTrigger",
    "sceneFamily": "editorial-warm",
    "motionRole": "gentle",
    "primaryState": "Story scene synchronized with chapter progress",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/scroll-story.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/scroll-story.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { gsap } from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\n\ngsap.registerPlugin(ScrollTrigger);\n\nexport type ScrollStoryChapter = {\n  id: string;\n  eyebrow?: string;\n  title: string;\n  copy?: string;\n  scene: ReactNode;\n};\n\nexport type ScrollStoryProps = {\n  chapters: readonly ScrollStoryChapter[];\n  label: string;\n  emptyLabel?: string;\n  height?: number;\n  className?: string;\n};\n\nexport function ScrollStory({ chapters, label, emptyLabel = \"No chapters available.\", height = 360, className = \"\" }: ScrollStoryProps) {\n  const root = useRef<HTMLDivElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n  const stage = useRef<HTMLDivElement>(null);\n  const sections = useRef(new Map<string, HTMLElement>());\n  const chaptersRef = useRef(chapters);\n  const lastActiveIndex = useRef(chapters.length > 0 ? 0 : -1);\n  const [activeId, setActiveId] = useState<string | null>(() => chapters[0]?.id ?? null);\n  const [reduced, setReduced] = useState<boolean | null>(null);\n  chaptersRef.current = chapters;\n\n  const matchedActiveIndex = activeId === null\n    ? -1\n    : chapters.findIndex((chapter) => chapter.id === activeId);\n  const activeIndex = matchedActiveIndex >= 0\n    ? matchedActiveIndex\n    : chapters.length > 0\n      ? activeId === null\n        ? 0\n        : Math.min(Math.max(lastActiveIndex.current, 0), chapters.length - 1)\n      : -1;\n  const current = chapters[activeIndex] ?? null;\n  const effectiveActiveId = current?.id ?? null;\n\n  useLayoutEffect(() => {\n    if (chapters.length === 0) {\n      lastActiveIndex.current = -1;\n      if (activeId !== null) setActiveId(null);\n      return;\n    }\n    if (matchedActiveIndex >= 0) {\n      lastActiveIndex.current = matchedActiveIndex;\n      return;\n    }\n    if (effectiveActiveId === null) return;\n    lastActiveIndex.current = activeIndex;\n    setActiveId(effectiveActiveId);\n  }, [activeId, activeIndex, chapters.length, effectiveActiveId, matchedActiveIndex]);\n\n  useLayoutEffect(() => {\n    const shell = root.current;\n    const scroll = scroller.current;\n    if (!shell || !scroll || chapters.length === 0) return;\n    const media = gsap.matchMedia();\n    media.add(\n      { reduceMotion: \"(prefers-reduced-motion: reduce)\" },\n      (context) => {\n        const reduce = Boolean(context.conditions?.reduceMotion);\n        setReduced(reduce);\n        const triggers = chapters.flatMap((chapter) => {\n          const section = sections.current.get(chapter.id);\n          if (!section) return [];\n          return ScrollTrigger.create({\n            trigger: section,\n            scroller: scroll,\n            start: \"top 58%\",\n            end: \"bottom 42%\",\n            onToggle: ({ isActive }) => {\n              if (!isActive) return;\n              const liveIndex = chaptersRef.current.findIndex((item) => item.id === chapter.id);\n              if (liveIndex < 0) return;\n              lastActiveIndex.current = liveIndex;\n              setActiveId(chapter.id);\n            },\n          });\n        });\n        return () => triggers.forEach((trigger) => trigger.kill());\n      },\n      shell,\n    );\n    return () => media.revert();\n  }, [chapters]);\n\n  useLayoutEffect(() => {\n    const node = stage.current;\n    if (!node || reduced === null) return;\n    if (reduced) {\n      gsap.set(node, { clearProps: \"transform,filter,opacity\" });\n      return;\n    }\n    const tween = gsap.fromTo(\n      node,\n      { opacity: 0.42, y: 14, scale: 0.985, filter: \"blur(5px)\" },\n      { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\", duration: 0.26, ease: \"power3.out\", overwrite: true },\n    );\n    return () => { tween.kill(); };\n  }, [effectiveActiveId, reduced]);\n\n  if (!current) {\n    return (\n      <div\n        role=\"group\"\n        aria-label={label}\n        className={`grid w-full min-w-0 place-items-center rounded-[10px] border border-neutral-200 bg-white p-4 text-center text-[12px] text-neutral-600 shadow-[0_4px_8px_-7px_rgba(28,25,23,.64)] dark:border-white/15 dark:bg-[#202020] dark:text-neutral-300 ${className}`}\n        style={{ height }}\n      >\n        <p role=\"status\">{emptyLabel}</p>\n      </div>\n    );\n  }\n\n  const scrollToChapter = (id: string) => {\n    const scroll = scroller.current;\n    const section = sections.current.get(id);\n    if (!scroll || !section) return;\n    const nextIndex = chapters.findIndex((chapter) => chapter.id === id);\n    if (nextIndex >= 0) {\n      lastActiveIndex.current = nextIndex;\n      setActiveId(id);\n    }\n    const scrollRect = scroll.getBoundingClientRect();\n    const sectionRect = section.getBoundingClientRect();\n    const sectionTop = scroll.scrollTop + sectionRect.top - scrollRect.top;\n    const centeredTop = sectionTop - (scroll.clientHeight - sectionRect.height) / 2;\n    scroll.scrollTo({\n      top: Math.max(0, centeredTop),\n      behavior: reduced === false ? \"smooth\" : \"auto\",\n    });\n  };\n\n  return (\n    <div\n      ref={root}\n      role=\"group\"\n      aria-label={label}\n      className={`grid w-full min-w-0 grid-cols-[minmax(0,.82fr)_minmax(0,1.18fr)] overflow-hidden rounded-[18px] border border-[#342d24]/20 bg-[#eee4d2] shadow-[0_22px_46px_-30px_rgba(28,20,12,.84)] dark:border-white/15 dark:bg-[#202020] ${className}`}\n      style={{ height }}\n    >\n        <div ref={scroller} className=\"overscroll-contain overflow-y-auto border-r border-[#342d24]/15 bg-[#eee4d2] px-3 py-[38%] [scrollbar-width:none] dark:border-white/10 dark:bg-[#1c1915]\">\n        {chapters.map((chapter) => (\n          <section\n            key={chapter.id}\n            ref={(node) => {\n              if (node) sections.current.set(chapter.id, node);\n              else sections.current.delete(chapter.id);\n            }}\n            className=\"grid min-h-[72%] content-center py-6\"\n          >\n            <button\n              type=\"button\"\n              aria-current={chapter.id === effectiveActiveId ? \"step\" : undefined}\n              onClick={() => scrollToChapter(chapter.id)}\n              className={`min-h-11 rounded-xl px-3 py-2.5 text-left outline-none transition-[background-color,color,transform] duration-200 focus-visible:shadow-[0_0_0_3px_rgba(69,104,255,.28)] ${chapter.id === effectiveActiveId ? \"bg-[#211c17] text-[#fff7ea] shadow-[0_10px_24px_-18px_rgba(35,25,15,.9)] dark:bg-[#f4e8d4] dark:text-[#211c17]\" : \"text-[#605648] hover:bg-white/50 dark:text-neutral-300 dark:hover:bg-white/5\"}`}\n            >\n              {chapter.eyebrow ? <span className=\"block text-[9px] opacity-65\">{chapter.eyebrow}</span> : null}\n              <strong className=\"mt-1 block text-[12px] leading-tight\">{chapter.title}</strong>\n              {chapter.copy ? <span className=\"mt-1 block text-[10px] leading-relaxed\">{chapter.copy}</span> : null}\n            </button>\n          </section>\n        ))}\n      </div>\n      <div className=\"relative grid min-w-0 place-items-center overflow-hidden bg-[#171612] p-4 before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_18%_18%,rgba(228,153,93,.36),transparent_34%),radial-gradient(circle_at_82%_72%,rgba(81,111,106,.34),transparent_42%)] before:content-[''] dark:bg-[#10110f]\">\n        <div aria-hidden className=\"pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,246,232,.08)_1px,transparent_1px),linear-gradient(90deg,rgba(255,246,232,.06)_1px,transparent_1px)] [background-size:28px_28px]\" />\n        <div ref={stage} key={current.id} className=\"relative z-10 w-full will-change-transform\">\n          {current.scene}\n        </div>\n        <div className=\"absolute bottom-3 right-3 flex items-center gap-1\" aria-hidden>\n          {chapters.map((chapter) => (\n            <span key={chapter.id} className=\"h-1 w-4 overflow-hidden rounded-full bg-neutral-500/20\">\n              <span\n                data-scroll-story-indicator\n                data-motion-mode={reduced === false ? \"standard\" : \"instant\"}\n                className={`block h-full rounded-full bg-[#f0a96c] dark:bg-[#f6d4a8] ${reduced === false ? `w-full origin-left transition-transform duration-150 ${chapter.id === effectiveActiveId ? \"scale-x-100\" : \"scale-x-25\"}` : chapter.id === effectiveActiveId ? \"w-full\" : \"w-1/4\"}`}\n              />\n            </span>\n          ))}\n        </div>\n        <span role=\"status\" className=\"sr-only\">{current.title}</span>\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}
