{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-focus",
  "title": "Scroll Focus",
  "description": "A column of images where the one in the center grows, with a title list that stays in sync.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/scroll-focus/scroll-focus.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport gsap from \"gsap\"\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\"\nimport { useGSAP } from \"@gsap/react\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\nimport { cn } from \"@/lib/utils\"\n\ngsap.registerPlugin(useGSAP, ScrollTrigger)\n\nexport interface ScrollFocusItem {\n  src: string\n  alt: string\n  /** Label shown in the synced list. */\n  title: string\n}\n\nexport interface ScrollFocusProps {\n  items: ScrollFocusItem[]\n  /** Side the synced title list sits on. Default: \"right\" */\n  side?: \"left\" | \"right\"\n  /** Width of images away from focus, as % of the section width. Default: 26 */\n  smallWidth?: number\n  /** Width of the focused image, as % of the section width. Default: 40 */\n  largeWidth?: number\n  /** Image aspect ratio. Default: \"2 / 1\" */\n  aspectRatio?: string\n  /** Vertical gap between images in px. Default: 14 */\n  gap?: number\n  /** Section heights of scrolling per image. Default: 0.5 */\n  scrollPerItem?: number\n  /** Scroll smoothing in seconds. Default: 0.8 */\n  scrub?: number\n  /** Show \"(1)\" index labels beside the images. Default: true */\n  showIndex?: boolean\n  /** Scroll container to track instead of the window (selector or element). */\n  scroller?: string | HTMLElement\n  /** Height of the pinned section (any CSS length). Default: \"100vh\" */\n  height?: string\n  className?: string\n  /** Classes for the title list. */\n  titleClassName?: string\n}\n\nconst tint = (i: number) =>\n  `hsl(${(i * 47) % 360} 42% 52% / 0.4)`\nconst hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => (e.currentTarget.style.visibility = \"hidden\")\n\nfunction ratio(aspect: string) {\n  const [w, h] = aspect.split(\"/\").map((v) => parseFloat(v))\n  return w && h ? w / h : 2\n}\n\nconst smooth = (t: number) => t * t * (3 - 2 * t)\n\nexport function ScrollFocus({\n  items,\n  side = \"right\",\n  smallWidth = 26,\n  largeWidth = 40,\n  aspectRatio = \"2 / 1\",\n  gap = 14,\n  scrollPerItem = 0.5,\n  scrub = 0.8,\n  showIndex = true,\n  scroller,\n  height = \"100vh\",\n  className,\n  titleClassName,\n}: ScrollFocusProps) {\n  const sectionRef = useRef<HTMLElement>(null)\n  const trackRef = useRef<HTMLDivElement>(null)\n  const reduced = useReducedMotion()\n  const [active, setActive] = useState(0)\n  const n = items.length\n\n  useEffect(() => {\n    sectionRef.current?.querySelectorAll(\"img\").forEach((img) => {\n      if (img.complete && img.naturalWidth === 0) img.style.visibility = \"hidden\"\n    })\n  }, [items])\n\n  useGSAP(\n    () => {\n      const section = sectionRef.current\n      const track = trackRef.current\n      if (!section || !track || !n) return\n      const els = gsap.utils.toArray<HTMLElement>(\"[data-focus-item]\", track)\n      const a = ratio(aspectRatio)\n      const state = { p: 0 }\n\n      // Size every image by its distance from the focus point, stack them,\n      // then shift the track so the focus point sits in the vertical center.\n      const layout = () => {\n        const W = section.clientWidth\n        const H = section.clientHeight\n        const sw = (W * smallWidth) / 100\n        const lw = (W * largeWidth) / 100\n        const sizes = els.map((_, i) => {\n          const t = smooth(Math.max(0, 1 - Math.abs(state.p - i)))\n          const w = sw + (lw - sw) * t\n          return { w, h: w / a }\n        })\n        let y = 0\n        const tops = sizes.map((s) => {\n          const top = y\n          y += s.h + gap\n          return top\n        })\n        const center = (i: number) => tops[i] + sizes[i].h / 2\n        const lo = Math.floor(state.p)\n        const hi = Math.min(lo + 1, n - 1)\n        const focus = center(lo) + (center(hi) - center(lo)) * (state.p - lo)\n\n        els.forEach((el, i) => {\n          el.style.width = `${sizes[i].w}px`\n          el.style.height = `${sizes[i].h}px`\n          el.style.transform = `translate(-50%, ${tops[i]}px)`\n        })\n        track.style.transform = `translateY(${H / 2 - focus}px)`\n      }\n\n      layout()\n      const ro = new ResizeObserver(layout)\n      ro.observe(section)\n\n      if (!reduced && n > 1) {\n        gsap.to(state, {\n          p: n - 1,\n          ease: \"none\",\n          onUpdate: () => {\n            layout()\n            setActive(Math.round(state.p))\n          },\n          scrollTrigger: {\n            trigger: section,\n            scroller,\n            start: \"top top\",\n            end: () => `+=${section.offsetHeight * scrollPerItem * (n - 1)}`,\n            pin: true,\n            scrub: scrub || true,\n            invalidateOnRefresh: true,\n          },\n        })\n      }\n\n      return () => ro.disconnect()\n    },\n    {\n      scope: sectionRef,\n      dependencies: [items, smallWidth, largeWidth, aspectRatio, gap, scrollPerItem, scrub, scroller, reduced],\n      revertOnUpdate: true,\n    }\n  )\n\n  return (\n    <section ref={sectionRef} className={cn(\"relative w-full overflow-hidden [container-type:inline-size]\", className)} style={{ height }}>\n      {/* image column */}\n      <div ref={trackRef} className=\"absolute inset-x-0 top-0 will-change-transform\">\n        {items.map((item, i) => (\n          <div key={i} data-focus-item className=\"absolute left-1/2 top-0\" style={{ width: `${smallWidth}%` }}>\n            <div className=\"size-full overflow-hidden\" style={{ background: tint(i) }}>\n              <img src={item.src} alt={item.alt} onError={hideBroken} draggable={false} className=\"size-full object-cover\" />\n            </div>\n            {showIndex && (\n              <span\n                className={cn(\n                  \"absolute bottom-0 font-mono text-[10px] tabular-nums opacity-70\",\n                  i % 2 ? \"left-full ml-3\" : \"right-full mr-3\"\n                )}\n              >\n                ({i + 1})\n              </span>\n            )}\n          </div>\n        ))}\n      </div>\n\n      {/* synced titles */}\n      <ul\n        className={cn(\n          \"absolute top-1/2 flex -translate-y-1/2 flex-col gap-0.5 font-mono uppercase\",\n          side === \"right\" ? \"right-[5%] items-end text-right\" : \"left-[5%] items-start text-left\",\n          titleClassName\n        )}\n      >\n        {items.map((item, i) => (\n          <li\n            key={i}\n            aria-current={i === active ? \"true\" : undefined}\n            className={cn(\n              \"leading-tight transition-all duration-300 ease-out\",\n              i === active\n                ? \"my-1 text-[clamp(1rem,2.6cqw,1.75rem)] font-bold tracking-tight\"\n                : \"text-[11px] tracking-wide opacity-40\"\n            )}\n          >\n            {item.title}\n          </li>\n        ))}\n      </ul>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/scroll-focus",
  "type": "registry:component"
}