{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-media-expansion",
  "type": "registry:ui",
  "title": "Scroll media expansion",
  "description": "Expands contained media into an immersive hero through chapter scrolling.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "hero-story"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/scroll-media-expansion/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "medium",
    "signature": "Media and viewport resolve into one continuous frame",
    "sceneFamily": "editorial-warm",
    "motionRole": "gentle",
    "primaryState": "Full-bleed coastal media with chapter rail",
    "assetProvenance": "local-asset"
  },
  "files": [
    {
      "path": "src/registry/components/scroll-media-expansion.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/scroll-media-expansion.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nexport type ScrollMediaExpansionSlide = {\n  id: string;\n  eyebrow: string;\n  title: string;\n  description: string;\n  image: string;\n  imageAlt: string;\n  accent?: string;\n};\n\nexport type ScrollMediaExpansionProps = {\n  slides: readonly ScrollMediaExpansionSlide[];\n  label?: string;\n  initialIndex?: number;\n  className?: string;\n  onChange?: (slide: ScrollMediaExpansionSlide, index: number) => void;\n};\n\nconst clamp = (value: number, max: number) => Math.max(0, Math.min(value, Math.max(0, max)));\n\nexport function ScrollMediaExpansion({\n  slides,\n  label = \"Scroll media expansion\",\n  initialIndex = 0,\n  className = \"\",\n  onChange,\n}: ScrollMediaExpansionProps) {\n  const reduced = useReducedMotion() === true;\n  const titleId = useId();\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const frameRef = useRef<number | null>(null);\n  const [activeIndex, setActiveIndex] = useState(() => clamp(initialIndex, slides.length));\n  const active = slides[activeIndex];\n\n  useEffect(() => {\n    setActiveIndex((index) => clamp(index, slides.length));\n  }, [slides.length]);\n\n  useEffect(() => () => {\n    if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n  }, []);\n\n  const select = (index: number, focus = false) => {\n    const nextIndex = clamp(index, slides.length);\n    const next = slides[nextIndex];\n    if (!next) return;\n    setActiveIndex(nextIndex);\n    onChange?.(next, nextIndex);\n    const target = itemRefs.current[nextIndex];\n    target?.scrollIntoView({ behavior: reduced ? \"auto\" : \"smooth\", block: \"center\" });\n    if (focus) target?.focus({ preventScroll: true });\n  };\n\n  const onScroll = () => {\n    if (frameRef.current !== null) return;\n    frameRef.current = requestAnimationFrame(() => {\n      frameRef.current = null;\n      const scroller = scrollerRef.current;\n      if (!scroller || slides.length === 0) return;\n      const center = scroller.getBoundingClientRect().top + scroller.clientHeight / 2;\n      let closest = activeIndex;\n      let distance = Number.POSITIVE_INFINITY;\n      itemRefs.current.forEach((item, index) => {\n        if (!item) return;\n        const rect = item.getBoundingClientRect();\n        const nextDistance = Math.abs(rect.top + rect.height / 2 - center);\n        if (nextDistance < distance) {\n          closest = index;\n          distance = nextDistance;\n        }\n      });\n      if (closest !== activeIndex && slides[closest]) {\n        setActiveIndex(closest);\n        onChange?.(slides[closest], closest);\n      }\n    });\n  };\n\n  if (!active) {\n    return <div role=\"status\" className={`grid min-h-64 place-items-center rounded-[18px] bg-stone-100 p-6 text-sm text-stone-500 ${className}`}>No media available.</div>;\n  }\n\n  return (\n    <section aria-labelledby={titleId} className={`overflow-hidden rounded-[18px] bg-[#1d211f] text-white shadow-[0_18px_60px_-30px_rgba(22,25,21,.72)] ${className}`}>\n      <div className=\"grid min-h-[430px] grid-rows-[minmax(250px,1fr)_180px] md:grid-cols-[minmax(0,1.35fr)_minmax(190px,.65fr)] md:grid-rows-1\">\n        <div className=\"relative isolate overflow-hidden bg-[#443d34]\">\n          <motion.img\n            key={active.id}\n            src={active.image}\n            alt={active.imageAlt}\n            initial={reduced ? false : { opacity: 0.35, scale: 0.92 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={{ duration: reduced ? 0 : 0.48, ease: [0.16, 1, 0.3, 1] }}\n            className=\"absolute inset-0 size-full object-cover\"\n          />\n          <div className=\"absolute inset-0 bg-[linear-gradient(90deg,rgba(16,17,15,.42),transparent_62%),linear-gradient(0deg,rgba(16,17,15,.6),transparent_48%)]\" />\n          <div className=\"absolute inset-x-5 bottom-5 max-w-md sm:inset-x-8 sm:bottom-8\">\n            <span className=\"font-mono text-[10px] uppercase tracking-[0.2em] text-white/75\">{active.eyebrow}</span>\n            <h3 id={titleId} className=\"mt-2 max-w-[13ch] text-3xl font-medium leading-[.96] tracking-[-.055em] sm:text-5xl\">{active.title}</h3>\n            <p className=\"mt-3 max-w-sm text-[12px] leading-relaxed text-white/78\">{active.description}</p>\n          </div>\n          <div className=\"absolute right-5 top-5 font-mono text-[10px] text-white/70\">{String(activeIndex + 1).padStart(2, \"0\")} / {String(slides.length).padStart(2, \"0\")}</div>\n        </div>\n        <div\n          ref={scrollerRef}\n          onScroll={onScroll}\n          onKeyDown={(event) => {\n            if (event.key === \"ArrowDown\" || event.key === \"ArrowRight\") { event.preventDefault(); select(activeIndex + 1, true); }\n            if (event.key === \"ArrowUp\" || event.key === \"ArrowLeft\") { event.preventDefault(); select(activeIndex - 1, true); }\n            if (event.key === \"Home\") { event.preventDefault(); select(0, true); }\n            if (event.key === \"End\") { event.preventDefault(); select(slides.length - 1, true); }\n          }}\n          tabIndex={0}\n          aria-label={`${label} chapters`}\n          className=\"flex overflow-x-auto overscroll-contain border-t border-white/10 bg-[#1d211f] p-2 outline-none [scrollbar-width:none] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#c3ff69] md:block md:overflow-y-auto md:border-l md:border-t-0 md:p-3\"\n        >\n          {slides.map((slide, index) => {\n            const selected = index === activeIndex;\n            return (\n              <button\n                key={slide.id}\n                ref={(node) => { itemRefs.current[index] = node; }}\n                type=\"button\"\n                aria-current={selected ? \"step\" : undefined}\n                onClick={() => select(index)}\n                className={`group relative min-h-36 min-w-[180px] overflow-hidden rounded-[12px] text-left outline-none md:mb-2 md:block md:min-w-0 ${selected ? \"ring-1 ring-white/55\" : \"opacity-65 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-[#c3ff69]\"}`}\n              >\n                <img src={slide.image} alt=\"\" className=\"absolute inset-0 size-full object-cover opacity-55 transition-transform duration-300 group-hover:scale-105\" />\n                <span className=\"absolute inset-0 bg-black/40\" />\n                <span className=\"relative flex min-h-36 flex-col justify-end p-3\">\n                  <span className=\"font-mono text-[9px] uppercase tracking-[.16em] text-white/70\">{String(index + 1).padStart(2, \"0\")}</span>\n                  <strong className=\"mt-1 text-sm leading-tight\">{slide.title}</strong>\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
    }
  ]
}
