"use client"; import { useCallback, useEffect, useRef, useState, type CSSProperties, } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { ArrowLeft, ArrowRight, Pause, Play } from "lucide-react"; import { cn } from "@/lib/utils"; gsap.registerPlugin(useGSAP); export interface ParallaxStripSlide { id: string; src: string; alt: string; title?: string; imagePosition?: string; } export interface ParallaxStripTransitionProps { slides: readonly ParallaxStripSlide[]; stripCount?: number; duration?: number; stripStagger?: number; zoomFrom?: number; autoplay?: boolean; autoplayInterval?: number; showControls?: boolean; reduceMotion?: boolean; className?: string; ariaLabel?: string; } const bounded = (value: number, min: number, max: number, fallback: number) => Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; export function ParallaxStripTransition(props: ParallaxStripTransitionProps) { return ( `${id}:${src}:${title}`) .join("|")} {...props} /> ); } function StripTransitionStage({ slides, stripCount = 10, duration = 0.85, stripStagger = 0.03, zoomFrom = 1.12, autoplay = false, autoplayInterval = 6000, showControls = true, reduceMotion = false, className, ariaLabel = "Image collection", }: ParallaxStripTransitionProps) { const rootRef = useRef(null); const busyRef = useRef(false); const touchRef = useRef<{ x: number; y: number } | null>(null); const hoveredRef = useRef(false); const [current, setCurrent] = useState(0); const [transition, setTransition] = useState<{ index: number; direction: 1 | -1; ready: boolean; } | null>(null); const [paused, setPaused] = useState(false); const [focused, setFocused] = useState(false); const [systemReducedMotion, setSystemReducedMotion] = useState(false); const total = slides.length; const count = Math.round(bounded(stripCount, 2, 20, 10)); const timing = bounded(duration, 0.35, 2, 0.85); const stagger = bounded(stripStagger, 0, 0.08, 0.03); const zoom = bounded(zoomFrom, 1, 1.4, 1.12); const interval = bounded(autoplayInterval, 3000, 30000, 6000); const active = slides[current]; const incoming = transition ? slides[transition.index] : null; const canAutoplay = autoplay && showControls && !reduceMotion && !systemReducedMotion && total > 1; const rotating = canAutoplay && !paused && !focused; useEffect(() => { const query = window.matchMedia("(prefers-reduced-motion: reduce)"); const update = () => setSystemReducedMotion(query.matches); update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); useEffect(() => { if (total < 2) return; const neighbors = new Set([ (current + 1) % total, (current - 1 + total) % total, ]); neighbors.forEach((index) => { const image = new Image(); image.src = slides[index].src; }); }, [current, slides, total]); const goTo = useCallback( (index: number, direction: 1 | -1) => { if (busyRef.current || total < 2) return; const next = ((index % total) + total) % total; if (next === current) return; busyRef.current = true; setTransition({ index: next, direction, ready: false }); }, [current, total], ); useGSAP( () => { if (!transition?.ready || !rootRef.current) return; const { index, direction } = transition; const finish = () => { setCurrent(index); setTransition(null); busyRef.current = false; }; const media = gsap.matchMedia(); media.add( { reduced: "(prefers-reduced-motion: reduce)", animated: "(prefers-reduced-motion: no-preference)", }, (context) => { if (reduceMotion || context.conditions?.reduced) { finish(); return; } const root = rootRef.current!; const strips = gsap.utils.toArray("[data-strip]", root); const images = gsap.utils.toArray( "[data-strip-image]", root, ); const revealTime = timing * 0.72; const revealEnd = revealTime + (count - 1) * stagger; const timeline = gsap.timeline({ defaults: { ease: "power3.out" }, onComplete: finish, }); timeline .set("[data-incoming]", { autoAlpha: 1 }, 0) .fromTo( direction === 1 ? strips : [...strips].reverse(), { clipPath: direction === 1 ? "inset(0 100% 0 0)" : "inset(0 0 0 100%)", }, { clipPath: "inset(0 0% 0 0%)", duration: revealTime, stagger }, 0, ) .fromTo( images, { scale: zoom }, { scale: 1, duration: Math.max(timing, revealEnd) }, 0, ); const currentNumber = root.querySelector("[data-current-number]"); const incomingNumber = root.querySelector("[data-incoming-number]"); if (currentNumber && incomingNumber) { timeline .to( currentNumber, { yPercent: -110 * direction, autoAlpha: 0, duration: timing * 0.7, ease: "power3.inOut", }, 0, ) .fromTo( incomingNumber, { yPercent: 110 * direction, autoAlpha: 0 }, { yPercent: 0, autoAlpha: 1, duration: timing * 0.8, ease: "power3.inOut", }, timing * 0.08, ); } }, rootRef, ); return () => media.revert(); }, { scope: rootRef, dependencies: [ transition, current, total, count, timing, stagger, zoom, reduceMotion, ], revertOnUpdate: true, }, ); useGSAP( () => { const root = rootRef.current; if (!root || !canAutoplay || paused || transition) return; const media = gsap.matchMedia(); media.add("(prefers-reduced-motion: no-preference)", () => { let timer: gsap.core.Tween | undefined; let visible = false; let disposed = false; const schedule = () => { timer?.kill(); if ( !disposed && visible && !document.hidden && !hoveredRef.current && !root.contains(document.activeElement) ) { timer = gsap.delayedCall(interval / 1000, () => goTo(current + 1, 1), ); } }; const stop = () => timer?.kill(); const onPointerEnter = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; hoveredRef.current = true; stop(); }; const onPointerLeave = () => { hoveredRef.current = false; schedule(); }; const onFocusOut = () => { stop(); timer = gsap.delayedCall(0, schedule); }; const observer = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; schedule(); }, { threshold: 0.25 }, ); observer.observe(root); root.addEventListener("pointerenter", onPointerEnter); root.addEventListener("pointerleave", onPointerLeave); root.addEventListener("focusin", stop); root.addEventListener("focusout", onFocusOut); document.addEventListener("visibilitychange", schedule); return () => { disposed = true; stop(); observer.disconnect(); root.removeEventListener("pointerenter", onPointerEnter); root.removeEventListener("pointerleave", onPointerLeave); root.removeEventListener("focusin", stop); root.removeEventListener("focusout", onFocusOut); document.removeEventListener("visibilitychange", schedule); }; }); return () => media.revert(); }, { scope: rootRef, dependencies: [ canAutoplay, paused, transition, current, interval, goTo, ], revertOnUpdate: true, }, ); const controlClass = "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"; return (
1 ? 0 : undefined} className={cn( "@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", className, )} style={ { "--strip-padding": "clamp(16px, 4cqw, 48px)", } as CSSProperties } onPointerEnter={(event) => { if (event.pointerType === "mouse") hoveredRef.current = true; }} onPointerLeave={() => { hoveredRef.current = false; }} onFocus={() => setFocused(true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false); }} onKeyDown={(event) => { if ( !showControls || event.altKey || event.ctrlKey || event.metaKey || (event.target as HTMLElement).closest( "input,textarea,select,[contenteditable=true]", ) ) return; const destinations: Record = { ArrowRight: [current + 1, 1], ArrowLeft: [current - 1, -1], Home: [0, -1], End: [total - 1, 1], }; if (destinations[event.key]) { event.preventDefault(); goTo(...destinations[event.key]); } }} onPointerDown={(event) => { if ( event.pointerType !== "touch" || (event.target as HTMLElement).closest("button,a") ) return; touchRef.current = { x: event.clientX, y: event.clientY }; }} onPointerUp={(event) => { const start = touchRef.current; touchRef.current = null; if (!start || !showControls) return; const dx = event.clientX - start.x; const dy = event.clientY - start.y; if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) { goTo(current + (dx < 0 ? 1 : -1), dx < 0 ? 1 : -1); } }} onPointerCancel={() => { touchRef.current = null; }} > {active ? ( <> {active.alt} {incoming && transition && ( )} {showControls && total > 1 && ( <> ); }