{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "ScrollStack-TS-TW",
	"title": "ScrollStack",
	"description": "Overlapping card stack reveals on scroll with depth layering.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "ScrollStack/ScrollStack.tsx",
			"content": "import React, { useLayoutEffect, useRef, useCallback } from 'react';\nimport type { ReactNode } from 'react';\nimport Lenis from 'lenis';\n\nexport interface ScrollStackItemProps {\n  itemClassName?: string;\n  children: ReactNode;\n}\n\nexport const ScrollStackItem: React.FC<ScrollStackItemProps> = ({ children, itemClassName = '' }) => (\n  <div\n    className={`scroll-stack-card relative w-full h-80 my-8 p-12 rounded-[40px] shadow-[0_0_30px_rgba(0,0,0,0.1)] box-border origin-top will-change-transform ${itemClassName}`.trim()}\n    style={{\n      backfaceVisibility: 'hidden',\n      transformStyle: 'preserve-3d'\n    }}\n  >\n    {children}\n  </div>\n);\n\ninterface ScrollStackProps {\n  className?: string;\n  children: ReactNode;\n  itemDistance?: number;\n  itemScale?: number;\n  itemStackDistance?: number;\n  stackPosition?: string;\n  scaleEndPosition?: string;\n  baseScale?: number;\n  scaleDuration?: number;\n  rotationAmount?: number;\n  blurAmount?: number;\n  useWindowScroll?: boolean;\n  onStackComplete?: () => void;\n}\n\nconst ScrollStack: React.FC<ScrollStackProps> = ({\n  children,\n  className = '',\n  itemDistance = 100,\n  itemScale = 0.03,\n  itemStackDistance = 30,\n  stackPosition = '20%',\n  scaleEndPosition = '10%',\n  baseScale = 0.85,\n  scaleDuration = 0.5,\n  rotationAmount = 0,\n  blurAmount = 0,\n  useWindowScroll = false,\n  onStackComplete\n}) => {\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const stackCompletedRef = useRef(false);\n  const animationFrameRef = useRef<number | null>(null);\n  const lenisRef = useRef<Lenis | null>(null);\n  const cardsRef = useRef<HTMLElement[]>([]);\n  const lastTransformsRef = useRef(new Map<number, any>());\n  const isUpdatingRef = useRef(false);\n\n  const calculateProgress = useCallback((scrollTop: number, start: number, end: number) => {\n    if (scrollTop < start) return 0;\n    if (scrollTop > end) return 1;\n    return (scrollTop - start) / (end - start);\n  }, []);\n\n  const parsePercentage = useCallback((value: string | number, containerHeight: number) => {\n    if (typeof value === 'string' && value.includes('%')) {\n      return (parseFloat(value) / 100) * containerHeight;\n    }\n    return parseFloat(value as string);\n  }, []);\n\n  const getScrollData = useCallback(() => {\n    if (useWindowScroll) {\n      return {\n        scrollTop: window.scrollY,\n        containerHeight: window.innerHeight,\n        scrollContainer: document.documentElement\n      };\n    } else {\n      const scroller = scrollerRef.current;\n      return {\n        scrollTop: scroller ? scroller.scrollTop : 0,\n        containerHeight: scroller ? scroller.clientHeight : 0,\n        scrollContainer: scroller\n      };\n    }\n  }, [useWindowScroll]);\n\n  const getElementOffset = useCallback(\n    (element: HTMLElement) => {\n      if (useWindowScroll) {\n        const rect = element.getBoundingClientRect();\n        return rect.top + window.scrollY;\n      } else {\n        return element.offsetTop;\n      }\n    },\n    [useWindowScroll]\n  );\n\n  const updateCardTransforms = useCallback(() => {\n    if (!cardsRef.current.length || isUpdatingRef.current) return;\n\n    isUpdatingRef.current = true;\n\n    const { scrollTop, containerHeight, scrollContainer } = getScrollData();\n    const stackPositionPx = parsePercentage(stackPosition, containerHeight);\n    const scaleEndPositionPx = parsePercentage(scaleEndPosition, containerHeight);\n\n    const endElement = useWindowScroll\n      ? (document.querySelector('.scroll-stack-end') as HTMLElement | null)\n      : (scrollerRef.current?.querySelector('.scroll-stack-end') as HTMLElement | null);\n\n    const endElementTop = endElement ? getElementOffset(endElement) : 0;\n\n    cardsRef.current.forEach((card, i) => {\n      if (!card) return;\n\n      const cardTop = getElementOffset(card);\n      const triggerStart = cardTop - stackPositionPx - itemStackDistance * i;\n      const triggerEnd = cardTop - scaleEndPositionPx;\n      const pinStart = cardTop - stackPositionPx - itemStackDistance * i;\n      const pinEnd = endElementTop - containerHeight / 2;\n\n      const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);\n      const targetScale = baseScale + i * itemScale;\n      const scale = 1 - scaleProgress * (1 - targetScale);\n      const rotation = rotationAmount ? i * rotationAmount * scaleProgress : 0;\n\n      let blur = 0;\n      if (blurAmount) {\n        let topCardIndex = 0;\n        for (let j = 0; j < cardsRef.current.length; j++) {\n          const jCardTop = getElementOffset(cardsRef.current[j]);\n          const jTriggerStart = jCardTop - stackPositionPx - itemStackDistance * j;\n          if (scrollTop >= jTriggerStart) {\n            topCardIndex = j;\n          }\n        }\n\n        if (i < topCardIndex) {\n          const depthInStack = topCardIndex - i;\n          blur = Math.max(0, depthInStack * blurAmount);\n        }\n      }\n\n      let translateY = 0;\n      const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;\n\n      if (isPinned) {\n        translateY = scrollTop - cardTop + stackPositionPx + itemStackDistance * i;\n      } else if (scrollTop > pinEnd) {\n        translateY = pinEnd - cardTop + stackPositionPx + itemStackDistance * i;\n      }\n\n      const newTransform = {\n        translateY: Math.round(translateY * 100) / 100,\n        scale: Math.round(scale * 1000) / 1000,\n        rotation: Math.round(rotation * 100) / 100,\n        blur: Math.round(blur * 100) / 100\n      };\n\n      const lastTransform = lastTransformsRef.current.get(i);\n      const hasChanged =\n        !lastTransform ||\n        Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||\n        Math.abs(lastTransform.scale - newTransform.scale) > 0.001 ||\n        Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 ||\n        Math.abs(lastTransform.blur - newTransform.blur) > 0.1;\n\n      if (hasChanged) {\n        const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`;\n        const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : '';\n\n        card.style.transform = transform;\n        card.style.filter = filter;\n\n        lastTransformsRef.current.set(i, newTransform);\n      }\n\n      if (i === cardsRef.current.length - 1) {\n        const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;\n        if (isInView && !stackCompletedRef.current) {\n          stackCompletedRef.current = true;\n          onStackComplete?.();\n        } else if (!isInView && stackCompletedRef.current) {\n          stackCompletedRef.current = false;\n        }\n      }\n    });\n\n    isUpdatingRef.current = false;\n  }, [\n    itemScale,\n    itemStackDistance,\n    stackPosition,\n    scaleEndPosition,\n    baseScale,\n    rotationAmount,\n    blurAmount,\n    useWindowScroll,\n    onStackComplete,\n    calculateProgress,\n    parsePercentage,\n    getScrollData,\n    getElementOffset\n  ]);\n\n  const handleScroll = useCallback(() => {\n    updateCardTransforms();\n  }, [updateCardTransforms]);\n\n  const setupLenis = useCallback(() => {\n    if (useWindowScroll) {\n      const lenis = new Lenis({\n        duration: 1.2,\n        easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),\n        smoothWheel: true,\n        touchMultiplier: 2,\n        infinite: false,\n        wheelMultiplier: 1,\n        lerp: 0.1,\n        syncTouch: true,\n        syncTouchLerp: 0.075\n      });\n\n      lenis.on('scroll', handleScroll);\n\n      const raf = (time: number) => {\n        lenis.raf(time);\n        animationFrameRef.current = requestAnimationFrame(raf);\n      };\n      animationFrameRef.current = requestAnimationFrame(raf);\n\n      lenisRef.current = lenis;\n      return lenis;\n    } else {\n      const scroller = scrollerRef.current;\n      if (!scroller) return;\n\n      const lenis = new Lenis({\n        wrapper: scroller,\n        content: scroller.querySelector('.scroll-stack-inner') as HTMLElement,\n        duration: 1.2,\n        easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),\n        smoothWheel: true,\n        touchMultiplier: 2,\n        infinite: false,\n        gestureOrientation: 'vertical',\n        wheelMultiplier: 1,\n        lerp: 0.1,\n        syncTouch: true,\n        syncTouchLerp: 0.075\n      });\n\n      lenis.on('scroll', handleScroll);\n\n      const raf = (time: number) => {\n        lenis.raf(time);\n        animationFrameRef.current = requestAnimationFrame(raf);\n      };\n      animationFrameRef.current = requestAnimationFrame(raf);\n\n      lenisRef.current = lenis;\n      return lenis;\n    }\n  }, [handleScroll, useWindowScroll]);\n\n  useLayoutEffect(() => {\n    if (!useWindowScroll && !scrollerRef.current) return;\n\n    const cards = Array.from(\n      useWindowScroll\n        ? document.querySelectorAll('.scroll-stack-card')\n        : (scrollerRef.current?.querySelectorAll('.scroll-stack-card') ?? [])\n    ) as HTMLElement[];\n    cardsRef.current = cards;\n    const transformsCache = lastTransformsRef.current;\n\n    cards.forEach((card, i) => {\n      if (i < cards.length - 1) {\n        card.style.marginBottom = `${itemDistance}px`;\n      }\n      card.style.willChange = 'transform, filter';\n      card.style.transformOrigin = 'top center';\n      card.style.backfaceVisibility = 'hidden';\n      card.style.transform = 'translateZ(0)';\n      card.style.webkitTransform = 'translateZ(0)';\n      card.style.perspective = '1000px';\n      card.style.webkitPerspective = '1000px';\n    });\n\n    setupLenis();\n\n    updateCardTransforms();\n\n    return () => {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current);\n      }\n      if (lenisRef.current) {\n        lenisRef.current.destroy();\n      }\n      stackCompletedRef.current = false;\n      cardsRef.current = [];\n      transformsCache.clear();\n      isUpdatingRef.current = false;\n    };\n  }, [\n    itemDistance,\n    itemScale,\n    itemStackDistance,\n    stackPosition,\n    scaleEndPosition,\n    baseScale,\n    scaleDuration,\n    rotationAmount,\n    blurAmount,\n    useWindowScroll,\n    onStackComplete,\n    setupLenis,\n    updateCardTransforms\n  ]);\n\n  return (\n    <div\n      className={`relative w-full h-full overflow-y-auto overflow-x-visible ${className}`.trim()}\n      ref={scrollerRef}\n      style={{\n        overscrollBehavior: 'contain',\n        WebkitOverflowScrolling: 'touch',\n        scrollBehavior: 'smooth',\n        WebkitTransform: 'translateZ(0)',\n        transform: 'translateZ(0)',\n        willChange: 'scroll-position'\n      }}\n    >\n      <div className=\"scroll-stack-inner pt-[20vh] px-20 pb-[50rem] min-h-screen\">\n        {children}\n        {/* Spacer so the last pin can release cleanly */}\n        <div className=\"scroll-stack-end w-full h-px\" />\n      </div>\n    </div>\n  );\n};\n\nexport default ScrollStack;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"lenis@^1.3.13"
	]
}