{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "parallax-strip-transition",
  "type": "registry:ui",
  "title": "Parallax Strip Transition",
  "description": "Full-bleed image scenes with directional strip reveals, a soft parallax zoom, and a smoothly rolling numeric counter.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/parallax-strip-transition.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { ArrowLeft, ArrowRight, Pause, Play } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface ParallaxStripSlide {\n  id: string;\n  src: string;\n  alt: string;\n  title?: string;\n  imagePosition?: string;\n}\n\nexport interface ParallaxStripTransitionProps {\n  slides: readonly ParallaxStripSlide[];\n  stripCount?: number;\n  duration?: number;\n  stripStagger?: number;\n  zoomFrom?: number;\n  autoplay?: boolean;\n  autoplayInterval?: number;\n  showControls?: boolean;\n  reduceMotion?: boolean;\n  className?: string;\n  ariaLabel?: string;\n}\n\nconst bounded = (value: number, min: number, max: number, fallback: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nexport function ParallaxStripTransition(props: ParallaxStripTransitionProps) {\n  return (\n    <StripTransitionStage\n      key={props.slides\n        .map(({ id, src, title }) => `${id}:${src}:${title}`)\n        .join(\"|\")}\n      {...props}\n    />\n  );\n}\n\nfunction StripTransitionStage({\n  slides,\n  stripCount = 10,\n  duration = 0.85,\n  stripStagger = 0.03,\n  zoomFrom = 1.12,\n  autoplay = false,\n  autoplayInterval = 6000,\n  showControls = true,\n  reduceMotion = false,\n  className,\n  ariaLabel = \"Image collection\",\n}: ParallaxStripTransitionProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const busyRef = useRef(false);\n  const touchRef = useRef<{ x: number; y: number } | null>(null);\n  const hoveredRef = useRef(false);\n  const [current, setCurrent] = useState(0);\n  const [transition, setTransition] = useState<{\n    index: number;\n    direction: 1 | -1;\n    ready: boolean;\n  } | null>(null);\n  const [paused, setPaused] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [systemReducedMotion, setSystemReducedMotion] = useState(false);\n  const total = slides.length;\n  const count = Math.round(bounded(stripCount, 2, 20, 10));\n  const timing = bounded(duration, 0.35, 2, 0.85);\n  const stagger = bounded(stripStagger, 0, 0.08, 0.03);\n  const zoom = bounded(zoomFrom, 1, 1.4, 1.12);\n  const interval = bounded(autoplayInterval, 3000, 30000, 6000);\n  const active = slides[current];\n  const incoming = transition ? slides[transition.index] : null;\n  const canAutoplay =\n    autoplay &&\n    showControls &&\n    !reduceMotion &&\n    !systemReducedMotion &&\n    total > 1;\n  const rotating = canAutoplay && !paused && !focused;\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const update = () => setSystemReducedMotion(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, []);\n\n  useEffect(() => {\n    if (total < 2) return;\n    const neighbors = new Set([\n      (current + 1) % total,\n      (current - 1 + total) % total,\n    ]);\n    neighbors.forEach((index) => {\n      const image = new Image();\n      image.src = slides[index].src;\n    });\n  }, [current, slides, total]);\n\n  const goTo = useCallback(\n    (index: number, direction: 1 | -1) => {\n      if (busyRef.current || total < 2) return;\n      const next = ((index % total) + total) % total;\n      if (next === current) return;\n      busyRef.current = true;\n      setTransition({ index: next, direction, ready: false });\n    },\n    [current, total],\n  );\n\n  useGSAP(\n    () => {\n      if (!transition?.ready || !rootRef.current) return;\n      const { index, direction } = transition;\n      const finish = () => {\n        setCurrent(index);\n        setTransition(null);\n        busyRef.current = false;\n      };\n      const media = gsap.matchMedia();\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          animated: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          if (reduceMotion || context.conditions?.reduced) {\n            finish();\n            return;\n          }\n          const root = rootRef.current!;\n          const strips = gsap.utils.toArray<HTMLElement>(\"[data-strip]\", root);\n          const images = gsap.utils.toArray<HTMLElement>(\n            \"[data-strip-image]\",\n            root,\n          );\n          const revealTime = timing * 0.72;\n          const revealEnd = revealTime + (count - 1) * stagger;\n          const timeline = gsap.timeline({\n            defaults: { ease: \"power3.out\" },\n            onComplete: finish,\n          });\n          timeline\n            .set(\"[data-incoming]\", { autoAlpha: 1 }, 0)\n            .fromTo(\n              direction === 1 ? strips : [...strips].reverse(),\n              {\n                clipPath:\n                  direction === 1 ? \"inset(0 100% 0 0)\" : \"inset(0 0 0 100%)\",\n              },\n              { clipPath: \"inset(0 0% 0 0%)\", duration: revealTime, stagger },\n              0,\n            )\n            .fromTo(\n              images,\n              { scale: zoom },\n              { scale: 1, duration: Math.max(timing, revealEnd) },\n              0,\n            );\n          const currentNumber = root.querySelector(\"[data-current-number]\");\n          const incomingNumber = root.querySelector(\"[data-incoming-number]\");\n          if (currentNumber && incomingNumber) {\n            timeline\n              .to(\n                currentNumber,\n                {\n                  yPercent: -110 * direction,\n                  autoAlpha: 0,\n                  duration: timing * 0.7,\n                  ease: \"power3.inOut\",\n                },\n                0,\n              )\n              .fromTo(\n                incomingNumber,\n                { yPercent: 110 * direction, autoAlpha: 0 },\n                {\n                  yPercent: 0,\n                  autoAlpha: 1,\n                  duration: timing * 0.8,\n                  ease: \"power3.inOut\",\n                },\n                timing * 0.08,\n              );\n          }\n        },\n        rootRef,\n      );\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        transition,\n        current,\n        total,\n        count,\n        timing,\n        stagger,\n        zoom,\n        reduceMotion,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      if (!root || !canAutoplay || paused || transition) return;\n      const media = gsap.matchMedia();\n      media.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        let timer: gsap.core.Tween | undefined;\n        let visible = false;\n        let disposed = false;\n        const schedule = () => {\n          timer?.kill();\n          if (\n            !disposed &&\n            visible &&\n            !document.hidden &&\n            !hoveredRef.current &&\n            !root.contains(document.activeElement)\n          ) {\n            timer = gsap.delayedCall(interval / 1000, () =>\n              goTo(current + 1, 1),\n            );\n          }\n        };\n        const stop = () => timer?.kill();\n        const onPointerEnter = (event: PointerEvent) => {\n          if (event.pointerType !== \"mouse\") return;\n          hoveredRef.current = true;\n          stop();\n        };\n        const onPointerLeave = () => {\n          hoveredRef.current = false;\n          schedule();\n        };\n        const onFocusOut = () => {\n          stop();\n          timer = gsap.delayedCall(0, schedule);\n        };\n        const observer = new IntersectionObserver(\n          ([entry]) => {\n            visible = entry.isIntersecting;\n            schedule();\n          },\n          { threshold: 0.25 },\n        );\n        observer.observe(root);\n        root.addEventListener(\"pointerenter\", onPointerEnter);\n        root.addEventListener(\"pointerleave\", onPointerLeave);\n        root.addEventListener(\"focusin\", stop);\n        root.addEventListener(\"focusout\", onFocusOut);\n        document.addEventListener(\"visibilitychange\", schedule);\n        return () => {\n          disposed = true;\n          stop();\n          observer.disconnect();\n          root.removeEventListener(\"pointerenter\", onPointerEnter);\n          root.removeEventListener(\"pointerleave\", onPointerLeave);\n          root.removeEventListener(\"focusin\", stop);\n          root.removeEventListener(\"focusout\", onFocusOut);\n          document.removeEventListener(\"visibilitychange\", schedule);\n        };\n      });\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        canAutoplay,\n        paused,\n        transition,\n        current,\n        interval,\n        goTo,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  const controlClass =\n    \"flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full border border-white/35 bg-black/15 text-white backdrop-blur-sm hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black aria-disabled:cursor-wait\";\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label={ariaLabel}\n      aria-busy={Boolean(transition)}\n      tabIndex={showControls && total > 1 ? 0 : undefined}\n      className={cn(\n        \"@container relative isolate h-150 min-h-72 w-full touch-pan-y overflow-hidden bg-zinc-950 font-sans text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70\",\n        className,\n      )}\n      style={\n        {\n          \"--strip-padding\": \"clamp(16px, 4cqw, 48px)\",\n        } as CSSProperties\n      }\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") hoveredRef.current = true;\n      }}\n      onPointerLeave={() => {\n        hoveredRef.current = false;\n      }}\n      onFocus={() => setFocused(true)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null))\n          setFocused(false);\n      }}\n      onKeyDown={(event) => {\n        if (\n          !showControls ||\n          event.altKey ||\n          event.ctrlKey ||\n          event.metaKey ||\n          (event.target as HTMLElement).closest(\n            \"input,textarea,select,[contenteditable=true]\",\n          )\n        )\n          return;\n        const destinations: Record<string, [number, 1 | -1]> = {\n          ArrowRight: [current + 1, 1],\n          ArrowLeft: [current - 1, -1],\n          Home: [0, -1],\n          End: [total - 1, 1],\n        };\n        if (destinations[event.key]) {\n          event.preventDefault();\n          goTo(...destinations[event.key]);\n        }\n      }}\n      onPointerDown={(event) => {\n        if (\n          event.pointerType !== \"touch\" ||\n          (event.target as HTMLElement).closest(\"button,a\")\n        )\n          return;\n        touchRef.current = { x: event.clientX, y: event.clientY };\n      }}\n      onPointerUp={(event) => {\n        const start = touchRef.current;\n        touchRef.current = null;\n        if (!start || !showControls) return;\n        const dx = event.clientX - start.x;\n        const dy = event.clientY - start.y;\n        if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {\n          goTo(current + (dx < 0 ? 1 : -1), dx < 0 ? 1 : -1);\n        }\n      }}\n      onPointerCancel={() => {\n        touchRef.current = null;\n      }}\n    >\n      {active ? (\n        <>\n          <img\n            src={active.src}\n            alt={active.alt}\n            draggable={false}\n            className=\"absolute inset-0 h-full w-full object-cover\"\n            style={{ objectPosition: active.imagePosition }}\n          />\n          {incoming && transition && (\n            <div\n              data-incoming=\"\"\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 invisible\"\n            >\n              {Array.from({ length: count }, (_, index) => (\n                <div\n                  key={index}\n                  data-strip=\"\"\n                  className=\"absolute inset-y-0 overflow-hidden\"\n                  style={{\n                    left: `${(index * 100) / count}%`,\n                    width: `calc(${100 / count}% + 0.5px)`,\n                    clipPath: \"inset(0 100% 0 0)\",\n                  }}\n                >\n                  <div\n                    className=\"absolute inset-y-0\"\n                    style={{\n                      left: `${(-index * 100) / count}cqw`,\n                      width: \"100cqw\",\n                    }}\n                  >\n                    <div\n                      data-strip-image=\"\"\n                      className=\"absolute inset-0 will-change-transform\"\n                    >\n                      <img\n                        src={incoming.src}\n                        alt=\"\"\n                        draggable={false}\n                        className=\"h-full w-full object-cover\"\n                        style={{ objectPosition: incoming.imagePosition }}\n                        onLoad={\n                          index === 0\n                            ? () =>\n                                setTransition((pending) =>\n                                  pending && !pending.ready\n                                    ? { ...pending, ready: true }\n                                    : pending,\n                                )\n                            : undefined\n                        }\n                        onError={\n                          index === 0\n                            ? () =>\n                                setTransition((pending) =>\n                                  pending && !pending.ready\n                                    ? { ...pending, ready: true }\n                                    : pending,\n                                )\n                            : undefined\n                        }\n                      />\n                    </div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          )}\n          {showControls && total > 1 && (\n            <>\n              <div\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-1/3 bg-linear-to-t from-black/35 to-transparent\"\n              />\n              <footer className=\"absolute inset-x-[var(--strip-padding)] bottom-[var(--strip-padding)] z-20\">\n                <div className=\"flex items-center justify-between gap-4\">\n                  <div\n                    aria-hidden=\"true\"\n                    className=\"flex items-baseline gap-3 font-mono tabular-nums\"\n                  >\n                    <div className=\"relative h-8 w-9 overflow-hidden text-2xl leading-8\">\n                      <span data-current-number=\"\" className=\"block\">\n                        {String(current + 1).padStart(2, \"0\")}\n                      </span>\n                      {transition && (\n                        <span\n                          data-incoming-number=\"\"\n                          className=\"absolute inset-0 invisible\"\n                        >\n                          {String(transition.index + 1).padStart(2, \"0\")}\n                        </span>\n                      )}\n                    </div>\n                    <span className=\"text-xs text-white/60\">\n                      {String(total).padStart(2, \"0\")}\n                    </span>\n                  </div>\n                  <div className=\"flex shrink-0 items-center gap-2\">\n                    {canAutoplay && (\n                      <button\n                        type=\"button\"\n                        aria-label={\n                          paused ? \"Resume slideshow\" : \"Pause slideshow\"\n                        }\n                        onClick={() => setPaused((value) => !value)}\n                        className={controlClass}\n                      >\n                        {paused ? (\n                          <Play size={16} aria-hidden=\"true\" />\n                        ) : (\n                          <Pause size={16} aria-hidden=\"true\" />\n                        )}\n                      </button>\n                    )}\n                    <button\n                      type=\"button\"\n                      aria-label=\"Previous page\"\n                      aria-disabled={Boolean(transition)}\n                      onClick={() => goTo(current - 1, -1)}\n                      className={controlClass}\n                    >\n                      <ArrowLeft size={18} aria-hidden=\"true\" />\n                    </button>\n                    <button\n                      type=\"button\"\n                      aria-label=\"Next page\"\n                      aria-disabled={Boolean(transition)}\n                      onClick={() => goTo(current + 1, 1)}\n                      className={controlClass}\n                    >\n                      <ArrowRight size={18} aria-hidden=\"true\" />\n                    </button>\n                  </div>\n                </div>\n              </footer>\n            </>\n          )}\n          <p\n            className=\"sr-only\"\n            role=\"status\"\n            aria-live={rotating ? \"off\" : \"polite\"}\n            aria-atomic=\"true\"\n          >\n            {active.title ?? active.alt}. Page {current + 1} of {total}.\n          </p>\n        </>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/parallax-strip-transition.tsx"
    }
  ]
}
