mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
1 line
9.0 KiB
JSON
1 line
9.0 KiB
JSON
{"name":"ScrollStack","title":"ScrollStack","description":"Overlapping card stack reveals on scroll with depth layering.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport Lenis from 'lenis';\nimport { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';\n\ninterface CardTransform {\n translateY: number;\n scale: number;\n rotation: number;\n blur: number;\n}\n\ninterface ScrollStackProps {\n className?: string;\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 onStackComplete?: () => void;\n}\n\nconst props = withDefaults(defineProps<ScrollStackProps>(), {\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});\n\nconst scrollerRef = useTemplateRef('scrollerRef');\nconst stackCompletedRef = ref(false);\nconst animationFrameRef = ref<number | null>(null);\nconst lenisRef = ref<Lenis | null>(null);\nconst cardsRef = ref<HTMLElement[]>([]);\nconst lastTransformsRef = ref(new Map<number, CardTransform>());\nconst isUpdatingRef = ref(false);\n\nconst calculateProgress = (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\nconst parsePercentage = (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\nconst updateCardTransforms = () => {\n const scroller = scrollerRef.value;\n if (!scroller || !cardsRef.value.length || isUpdatingRef.value) return;\n\n isUpdatingRef.value = true;\n\n const scrollTop = scroller.scrollTop;\n const containerHeight = scroller.clientHeight;\n const stackPositionPx = parsePercentage(props.stackPosition, containerHeight);\n const scaleEndPositionPx = parsePercentage(props.scaleEndPosition, containerHeight);\n const endElement = scroller.querySelector('.scroll-stack-end') as HTMLElement;\n const endElementTop = endElement ? endElement.offsetTop : 0;\n\n cardsRef.value.forEach((card, i) => {\n if (!card) return;\n\n const cardTop = card.offsetTop;\n const triggerStart = cardTop - stackPositionPx - props.itemStackDistance * i;\n const triggerEnd = cardTop - scaleEndPositionPx;\n const pinStart = cardTop - stackPositionPx - props.itemStackDistance * i;\n const pinEnd = endElementTop - containerHeight / 2;\n\n const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);\n const targetScale = props.baseScale + i * props.itemScale;\n const scale = 1 - scaleProgress * (1 - targetScale);\n const rotation = props.rotationAmount ? i * props.rotationAmount * scaleProgress : 0;\n\n let blur = 0;\n if (props.blurAmount) {\n let topCardIndex = 0;\n for (let j = 0; j < cardsRef.value.length; j++) {\n const jCardTop = cardsRef.value[j].offsetTop;\n const jTriggerStart = jCardTop - stackPositionPx - props.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 * props.blurAmount);\n }\n }\n\n let translateY = 0;\n const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;\n\n if (isPinned) {\n translateY = scrollTop - cardTop + stackPositionPx + props.itemStackDistance * i;\n } else if (scrollTop > pinEnd) {\n translateY = pinEnd - cardTop + stackPositionPx + props.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.value.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.value.set(i, newTransform);\n }\n\n if (i === cardsRef.value.length - 1) {\n const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;\n if (isInView && !stackCompletedRef.value) {\n stackCompletedRef.value = true;\n props.onStackComplete?.();\n } else if (!isInView && stackCompletedRef.value) {\n stackCompletedRef.value = false;\n }\n }\n });\n\n isUpdatingRef.value = false;\n};\n\nconst handleScroll = () => {\n updateCardTransforms();\n};\n\nconst setupLenis = () => {\n const scroller = scrollerRef.value;\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.value = requestAnimationFrame(raf);\n };\n animationFrameRef.value = requestAnimationFrame(raf);\n\n lenisRef.value = lenis;\n return lenis;\n};\n\nlet cleanup: (() => void) | null = null;\nconst setup = () => {\n const scroller = scrollerRef.value;\n if (!scroller) return;\n\n const cards = Array.from(scroller.querySelectorAll('.scroll-stack-card')) as HTMLElement[];\n cardsRef.value = cards;\n const transformsCache = lastTransformsRef.value;\n\n cards.forEach((card, i) => {\n if (i < cards.length - 1) {\n card.style.marginBottom = `${props.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 cleanup = () => {\n if (animationFrameRef.value) {\n cancelAnimationFrame(animationFrameRef.value);\n }\n if (lenisRef.value) {\n lenisRef.value.destroy();\n }\n stackCompletedRef.value = false;\n cardsRef.value = [];\n transformsCache.clear();\n isUpdatingRef.value = false;\n };\n};\n\nonMounted(async () => {\n await nextTick();\n setup();\n});\n\nonBeforeUnmount(() => {\n cleanup?.();\n});\n\nwatch(\n () => props,\n () => {\n cleanup?.();\n setup();\n },\n { deep: true }\n);\n</script>\n\n<script lang=\"ts\">\nexport const ScrollStackItem = defineComponent({\n name: 'ScrollStackItem',\n props: {\n itemClassName: {\n type: String,\n default: ''\n }\n },\n setup(props, { slots }) {\n return () =>\n h(\n 'div',\n {\n class:\n `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 ${props.itemClassName}`.trim(),\n style: {\n backfaceVisibility: 'hidden',\n transformStyle: 'preserve-3d'\n }\n },\n slots.default?.()\n );\n }\n});\n</script>\n\n<template>\n <div\n ref=\"scrollerRef\"\n :class=\"['relative w-full h-full overflow-y-auto overflow-x-visible', className]\"\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 class=\"px-20 pt-[20vh] pb-[50rem] min-h-screen scroll-stack-inner\">\n <slot />\n <!-- Spacer so the last pin can release cleanly -->\n <div class=\"w-full h-px scroll-stack-end\" />\n </div>\n </div>\n</template>\n","path":"ScrollStack/ScrollStack.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"lenis","version":"^1.3.8"}],"devDependencies":[],"categories":["Components"]} |