{
  "slug": "parallax-strip-transition",
  "name": "Parallax Strip Transition",
  "description": "Full-bleed image scenes with directional strip reveals, a soft parallax zoom, and a smoothly rolling numeric counter.",
  "summary": "An image carousel that reveals each scene through vertical strips. One GSAP timeline coordinates image masks, zoom, and directional number changes. Tune the motion in Component Studio.",
  "category": "page-transitions",
  "tags": [
    "page transitions",
    "parallax",
    "strips",
    "images",
    "gsap"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-08",
  "updatedDate": "2026-10-08",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Image-only scenes with no headings, captions, or progress bar",
    "Directional strip reveals reverse when moving backwards",
    "Pixel-aligned full-bleed imagery with adjustable crops",
    "Smooth rolling numbers share the image transition timeline",
    "Arrow buttons, arrow keys, Home/End, and horizontal touch gestures",
    "Optional autoplay pauses on hover, focus, hidden tabs, and offscreen content",
    "Instant changes for system or explicit reduced-motion preferences",
    "Scoped GSAP cleanup with guarded rapid navigation"
  ],
  "anatomy": [
    "<ParallaxStripTransition> named, focusable carousel region",
    "Current full-bleed image scene",
    "Temporary incoming strips with shared image geometry",
    "Masked rolling current number and static total",
    "Icon-only arrow controls and optional autoplay pause",
    "Screen-reader-only completed-scene announcements"
  ],
  "physics": {
    "engine": "GSAP",
    "description": "A single scoped timeline reveals directional strip masks while easing every image fragment from the same scale to 1. The current number rolls out as the incoming number rolls in, using synchronized power3 easing. Incoming scenes wait for their image to load; adjacent images are preloaded.",
    "parameters": [
      {
        "label": "Duration",
        "value": "0.85s"
      },
      {
        "label": "Strips",
        "value": "10"
      },
      {
        "label": "Strip stagger",
        "value": "0.03s"
      },
      {
        "label": "Image zoom",
        "value": "1.12×"
      }
    ]
  },
  "accessibility": {
    "role": "Named region with carousel description",
    "keyboard": [
      {
        "key": "Tab",
        "description": "Focus the collection and its icon controls."
      },
      {
        "key": "Arrow Left / Arrow Right",
        "description": "Navigate to the previous or next scene."
      },
      {
        "key": "Home / End",
        "description": "Navigate to the first or last scene."
      },
      {
        "key": "Enter / Space",
        "description": "Activate the focused arrow or autoplay control."
      }
    ],
    "aria": "Descriptive image alt text, icon buttons with accessible names, busy state, and screen-reader-only completed-scene announcements. Decorative strips and rolling numbers are hidden from assistive technology.",
    "reducedMotion": "Skips image zoom, strip wipes, and number rolling. Automatic playback is disabled when reduced motion is requested."
  },
  "guidelines": {
    "recommended": [
      "Photography portfolio chapters",
      "Editorial landing pages and visual collections",
      "Product campaign scenes"
    ],
    "bestPractices": [
      "Supply unique slide ids and descriptive image alt text. An optional title is used only for accessible announcements.",
      "Use landscape images and imagePosition to preserve important subjects.",
      "Give the component a defined height when embedding it in a panel.",
      "Scene navigation is local to the component; it does not intercept browser routes or history.",
      "Keep autoplay optional and retain visible pause and navigation controls."
    ]
  },
  "props": [
    {
      "name": "slides",
      "type": "readonly ParallaxStripSlide[]",
      "description": "Images with unique id, src, alt, optional imagePosition, and optional title for screen-reader announcements.",
      "required": true
    },
    {
      "name": "stripCount",
      "type": "number",
      "description": "Number of aligned vertical image strips, clamped to 2–20.",
      "defaultValue": "10"
    },
    {
      "name": "duration",
      "type": "number",
      "description": "Image zoom and rolling-number duration in seconds, clamped to 0.35–2.",
      "defaultValue": "0.85"
    },
    {
      "name": "stripStagger",
      "type": "number",
      "description": "Delay between strip reveals in seconds, clamped to 0–0.08.",
      "defaultValue": "0.03"
    },
    {
      "name": "zoomFrom",
      "type": "number",
      "description": "Incoming image scale, clamped to 1–1.4.",
      "defaultValue": "1.12"
    },
    {
      "name": "autoplay",
      "type": "boolean",
      "description": "Automatically navigate when visible. Requires showControls so the pause button is available. Pauses on hover, focus, hidden tabs, and reduced motion.",
      "defaultValue": "false"
    },
    {
      "name": "autoplayInterval",
      "type": "number",
      "description": "Idle time between completed transitions in milliseconds, clamped to 3000–30000.",
      "defaultValue": "6000"
    },
    {
      "name": "showControls",
      "type": "boolean",
      "description": "Show the rolling numeric counter, icon arrows, and optional autoplay controls. Also enables keyboard and touch navigation.",
      "defaultValue": "true"
    },
    {
      "name": "reduceMotion",
      "type": "boolean",
      "description": "Force instant scene changes. The system reduced-motion preference is always respected.",
      "defaultValue": "false"
    },
    {
      "name": "className",
      "type": "string",
      "description": "Additional classes for the default 600px frame. Use an explicit height inside containers."
    },
    {
      "name": "ariaLabel",
      "type": "string",
      "description": "Accessible name of the carousel region.",
      "defaultValue": "\"Image collection\""
    }
  ],
  "usage": "import { ParallaxStripTransition } from \"@/components/ui/parallax-strip-transition\";\n\nconst slides = [\n  { id: \"coast\", src: \"/images/coast.jpg\", alt: \"Rocky coastline\", title: \"Coast\" },\n  { id: \"forest\", src: \"/images/forest.jpg\", alt: \"Pine forest\", title: \"Forest\" },\n];\n\nexport function ParallaxStripTransitionDemo() {\n  return <ParallaxStripTransition slides={slides} className=\"h-[520px]\" />;\n}",
  "files": [
    {
      "name": "parallax-strip-transition.tsx",
      "path": "registry/ui/parallax-strip-transition.tsx",
      "code": "\"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",
      "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"
    }
  ],
  "type": "registry:ui",
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "title": "Parallax Strip Transition"
}
