"use client"; import { useId, useRef, type CSSProperties } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { ArrowDown } from "lucide-react"; import { cn } from "@/lib/utils"; gsap.registerPlugin(useGSAP, ScrollTrigger); export interface ScrollingCardItem { id: string; image: string; imageAlt: string; imagePosition?: string; color?: string; } export interface ScrollingCardsProps { items: readonly ScrollingCardItem[]; title?: string; subtitle?: string; eyebrow?: string; stackGap?: number; scaleStep?: number; stiffness?: number; friction?: number; cardHeight?: number; borderRadius?: number; showIntro?: 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 ScrollingCards({ items, title = "A story worth scrolling.", subtitle = "Scroll down. Let every card become part of the story.", eyebrow = "THE COLLECTION", stackGap = 24, scaleStep = 0.05, stiffness = 260, friction = 32, cardHeight = 420, borderRadius = 24, showIntro = true, className, ariaLabel = "Scrolling card collection", }: ScrollingCardsProps) { const rootRef = useRef(null); const collectionRef = useRef(null); const headingId = useId(); const gap = bounded(stackGap, 0, 40, 24); const scale = bounded(scaleStep, 0, 0.1, 0.05); const tension = bounded(stiffness, 80, 500, 260); const damping = bounded(friction, 12, 60, 32); const height = bounded(cardHeight, 240, 600, 420); const radius = bounded(borderRadius, 0, 40, 24); useGSAP( () => { const root = rootRef.current; const collection = collectionRef.current; if (!root || !collection || items.length === 0) return; const cards = gsap.utils.toArray( "[data-scrolling-card]", root, ); const stages = gsap.utils.toArray( "[data-scrolling-stage]", root, ); const media = gsap.matchMedia(); media.add( { reduce: "(prefers-reduced-motion: reduce)", animate: "(prefers-reduced-motion: no-preference)", }, (context) => { const reduced = Boolean(context.conditions?.reduce); root.dataset.reducedMotion = String(reduced); let viewportHeight = 0; let viewportWidth = 0; let fittedGap = 0; const measure = () => { viewportHeight = root.clientHeight; viewportWidth = root.clientWidth; const available = Math.max(0, viewportHeight - 64); fittedGap = Math.min(gap, available / Math.max(1, items.length + 3)); const fittedHeight = Math.min( height, Math.max(0, available - (items.length - 1) * fittedGap), ); root.style.setProperty("--sc-viewport", `${viewportHeight}px`); root.style.setProperty("--sc-height", `${fittedHeight}px`); root.style.setProperty( "--sc-top", `${Math.max(24, (viewportHeight - fittedHeight - (items.length - 1) * fittedGap) / 2)}px`, ); }; measure(); let timeline: gsap.core.Timeline | undefined; let trigger: ScrollTrigger | undefined; let position = 0; let target = 0; let velocity = 0; let ticking = false; const stop = () => { gsap.ticker.remove(tick); ticking = false; }; const tick = (_time: number, deltaTime: number) => { const elapsed = Math.min(deltaTime / 1000, 0.064); const steps = Math.max(1, Math.ceil(elapsed / (1 / 120))); const step = elapsed / steps; for (let index = 0; index < steps; index++) { velocity += (tension * (target - position) - damping * velocity) * step; position += velocity * step; } timeline?.progress(gsap.utils.clamp(0, 1, position)); if ( Math.abs(target - position) < 0.00005 && Math.abs(velocity) < 0.0005 ) { position = target; velocity = 0; timeline?.progress(target); stop(); } }; if (!reduced && items.length > 1) { gsap.set(stages, { y: (index) => (index === 0 ? 0 : viewportHeight) }); gsap.set(cards, { scale: 1, transformOrigin: "center top" }); const stageSetters = stages.map((stage) => gsap.quickSetter(stage, "y", "px"), ); const scaleSetters = cards.map((card) => gsap.quickSetter(card, "scale"), ); const stackTimeline = gsap.timeline({ paused: true, defaults: { duration: 1, ease: "none" }, }); timeline = stackTimeline; stages.slice(1).forEach((stage, index) => { const cardIndex = index + 1; stackTimeline.fromTo( stage, { y: () => viewportHeight }, { y: () => cardIndex * fittedGap, immediateRender: false }, index, ); cards.slice(0, cardIndex).forEach((card, previousIndex) => { stackTimeline.to( card, { scale: Math.max(0.65, 1 - (cardIndex - previousIndex) * scale) }, index, ); }); }); const refresh = (self: ScrollTrigger) => { stop(); position = target = self.progress; velocity = 0; stageSetters.forEach((setY, index) => setY(index === 0 ? 0 : viewportHeight), ); scaleSetters.forEach((setScale) => setScale(1)); timeline?.invalidate().progress(position); }; trigger = ScrollTrigger.create({ scroller: root, trigger: collection, start: () => collection.offsetTop, end: () => collection.offsetTop + (items.length - 1) * viewportHeight, onUpdate: (self) => { target = self.progress; if (!ticking) { ticking = true; gsap.ticker.add(tick); } }, onRefresh: (self) => refresh(self), }); } const observer = new ResizeObserver(() => { if ( root.clientHeight === viewportHeight && root.clientWidth === viewportWidth ) return; measure(); trigger?.refresh(); }); observer.observe(root); return () => { observer.disconnect(); stop(); delete root.dataset.reducedMotion; ["--sc-viewport", "--sc-height", "--sc-top"].forEach((property) => root.style.removeProperty(property), ); }; }, root, ); return () => media.revert(); }, { scope: rootRef, dependencies: [items, gap, scale, tension, damping, height, showIntro], revertOnUpdate: true, }, ); return (
{showIntro && (

{eyebrow}

{title}

{subtitle}

Scroll to explore
)}
    {items.map((item, index) => (
  1. {item.imageAlt}
  2. ))}
); } export default ScrollingCards;