Merge pull request #52 from Utkarsh-Singhal-26/feat/scroll-stack

Added <ScrollStack /> component
This commit is contained in:
David
2025-07-23 22:09:35 +03:00
committed by GitHub
6 changed files with 625 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
// Highlighted sidebar items // Highlighted sidebar items
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel']; export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel', 'Scroll Stack'];
export const UPDATED = []; export const UPDATED = [];
// Used for main sidebar navigation // Used for main sidebar navigation
@@ -63,6 +63,7 @@ export const CATEGORIES = [
'Masonry', 'Masonry',
'Glass Surface', 'Glass Surface',
'Magic Bento', 'Magic Bento',
'Scroll Stack',
'Profile Card', 'Profile Card',
'Dock', 'Dock',
'Gooey Nav', 'Gooey Nav',
@@ -80,7 +81,7 @@ export const CATEGORIES = [
'Flowing Menu', 'Flowing Menu',
'Elastic Slider', 'Elastic Slider',
'Stack', 'Stack',
'Chroma Grid' 'Chroma Grid',
] ]
}, },
{ {

View File

@@ -69,6 +69,7 @@ const components = {
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'), 'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
'stack': () => import('../demo/Components/StackDemo.vue'), 'stack': () => import('../demo/Components/StackDemo.vue'),
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'), 'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
'scroll-stack': () => import('../demo/Components/ScrollStackDemo.vue'),
}; };
const backgrounds = { const backgrounds = {

View File

@@ -0,0 +1,26 @@
import code from '@content/Components/ScrollStack/ScrollStack.vue?raw';
import { createCodeObject } from '../../../types/code';
export const scrollStack = createCodeObject(code, 'Components/ScrollStack', {
installation: `npm install lenis`,
usage: `<template>
<ScrollStack>
<ScrollStackItem>
<h2>Card 1</h2>
<p>This is the first card in the stack</p>
</ScrollStackItem>
<ScrollStackItem>
<h2>Card 2</h2>
<p>This is the second card in the stack</p>
</ScrollStackItem>
<ScrollStackItem>
<h2>Card 3</h2>
<p>This is the third card in the stack</p>
</ScrollStackItem>
</ScrollStack>
</template>
<script setup lang="ts">
import ScrollStack, { ScrollStackItem } from "./ScrollStack.vue";
</script>`
});

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import Lenis from 'lenis';
import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
interface CardTransform {
translateY: number;
scale: number;
rotation: number;
blur: number;
}
interface ScrollStackProps {
className?: string;
itemDistance?: number;
itemScale?: number;
itemStackDistance?: number;
stackPosition?: string;
scaleEndPosition?: string;
baseScale?: number;
scaleDuration?: number;
rotationAmount?: number;
blurAmount?: number;
onStackComplete?: () => void;
}
const props = withDefaults(defineProps<ScrollStackProps>(), {
className: '',
itemDistance: 100,
itemScale: 0.03,
itemStackDistance: 30,
stackPosition: '20%',
scaleEndPosition: '10%',
baseScale: 0.85,
scaleDuration: 0.5,
rotationAmount: 0,
blurAmount: 0
});
const scrollerRef = useTemplateRef('scrollerRef');
const stackCompletedRef = ref(false);
const animationFrameRef = ref<number | null>(null);
const lenisRef = ref<Lenis | null>(null);
const cardsRef = ref<HTMLElement[]>([]);
const lastTransformsRef = ref(new Map<number, CardTransform>());
const isUpdatingRef = ref(false);
const calculateProgress = (scrollTop: number, start: number, end: number) => {
if (scrollTop < start) return 0;
if (scrollTop > end) return 1;
return (scrollTop - start) / (end - start);
};
const parsePercentage = (value: string | number, containerHeight: number) => {
if (typeof value === 'string' && value.includes('%')) {
return (parseFloat(value) / 100) * containerHeight;
}
return parseFloat(value as string);
};
const updateCardTransforms = () => {
const scroller = scrollerRef.value;
if (!scroller || !cardsRef.value.length || isUpdatingRef.value) return;
isUpdatingRef.value = true;
const scrollTop = scroller.scrollTop;
const containerHeight = scroller.clientHeight;
const stackPositionPx = parsePercentage(props.stackPosition, containerHeight);
const scaleEndPositionPx = parsePercentage(props.scaleEndPosition, containerHeight);
const endElement = scroller.querySelector('.scroll-stack-end') as HTMLElement;
const endElementTop = endElement ? endElement.offsetTop : 0;
cardsRef.value.forEach((card, i) => {
if (!card) return;
const cardTop = card.offsetTop;
const triggerStart = cardTop - stackPositionPx - props.itemStackDistance * i;
const triggerEnd = cardTop - scaleEndPositionPx;
const pinStart = cardTop - stackPositionPx - props.itemStackDistance * i;
const pinEnd = endElementTop - containerHeight / 2;
const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);
const targetScale = props.baseScale + i * props.itemScale;
const scale = 1 - scaleProgress * (1 - targetScale);
const rotation = props.rotationAmount ? i * props.rotationAmount * scaleProgress : 0;
let blur = 0;
if (props.blurAmount) {
let topCardIndex = 0;
for (let j = 0; j < cardsRef.value.length; j++) {
const jCardTop = cardsRef.value[j].offsetTop;
const jTriggerStart = jCardTop - stackPositionPx - props.itemStackDistance * j;
if (scrollTop >= jTriggerStart) {
topCardIndex = j;
}
}
if (i < topCardIndex) {
const depthInStack = topCardIndex - i;
blur = Math.max(0, depthInStack * props.blurAmount);
}
}
let translateY = 0;
const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;
if (isPinned) {
translateY = scrollTop - cardTop + stackPositionPx + props.itemStackDistance * i;
} else if (scrollTop > pinEnd) {
translateY = pinEnd - cardTop + stackPositionPx + props.itemStackDistance * i;
}
const newTransform = {
translateY: Math.round(translateY * 100) / 100,
scale: Math.round(scale * 1000) / 1000,
rotation: Math.round(rotation * 100) / 100,
blur: Math.round(blur * 100) / 100
};
const lastTransform = lastTransformsRef.value.get(i);
const hasChanged =
!lastTransform ||
Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||
Math.abs(lastTransform.scale - newTransform.scale) > 0.001 ||
Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 ||
Math.abs(lastTransform.blur - newTransform.blur) > 0.1;
if (hasChanged) {
const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`;
const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : '';
card.style.transform = transform;
card.style.filter = filter;
lastTransformsRef.value.set(i, newTransform);
}
if (i === cardsRef.value.length - 1) {
const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;
if (isInView && !stackCompletedRef.value) {
stackCompletedRef.value = true;
props.onStackComplete?.();
} else if (!isInView && stackCompletedRef.value) {
stackCompletedRef.value = false;
}
}
});
isUpdatingRef.value = false;
};
const handleScroll = () => {
updateCardTransforms();
};
const setupLenis = () => {
const scroller = scrollerRef.value;
if (!scroller) return;
const lenis = new Lenis({
wrapper: scroller,
content: scroller.querySelector('.scroll-stack-inner') as HTMLElement,
duration: 1.2,
easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
touchMultiplier: 2,
infinite: false,
gestureOrientation: 'vertical',
wheelMultiplier: 1,
lerp: 0.1,
syncTouch: true,
syncTouchLerp: 0.075
});
lenis.on('scroll', handleScroll);
const raf = (time: number) => {
lenis.raf(time);
animationFrameRef.value = requestAnimationFrame(raf);
};
animationFrameRef.value = requestAnimationFrame(raf);
lenisRef.value = lenis;
return lenis;
};
let cleanup: (() => void) | null = null;
const setup = () => {
const scroller = scrollerRef.value;
if (!scroller) return;
const cards = Array.from(scroller.querySelectorAll('.scroll-stack-card')) as HTMLElement[];
cardsRef.value = cards;
const transformsCache = lastTransformsRef.value;
cards.forEach((card, i) => {
if (i < cards.length - 1) {
card.style.marginBottom = `${props.itemDistance}px`;
}
card.style.willChange = 'transform, filter';
card.style.transformOrigin = 'top center';
card.style.backfaceVisibility = 'hidden';
card.style.transform = 'translateZ(0)';
card.style.webkitTransform = 'translateZ(0)';
card.style.perspective = '1000px';
card.style.webkitPerspective = '1000px';
});
setupLenis();
updateCardTransforms();
cleanup = () => {
if (animationFrameRef.value) {
cancelAnimationFrame(animationFrameRef.value);
}
if (lenisRef.value) {
lenisRef.value.destroy();
}
stackCompletedRef.value = false;
cardsRef.value = [];
transformsCache.clear();
isUpdatingRef.value = false;
};
};
onMounted(async () => {
await nextTick();
setup();
});
onBeforeUnmount(() => {
cleanup?.();
});
watch(
() => props,
() => {
cleanup?.();
setup();
},
{ deep: true }
);
</script>
<script lang="ts">
export const ScrollStackItem = defineComponent({
name: 'ScrollStackItem',
props: {
itemClassName: {
type: String,
default: ''
}
},
setup(props, { slots }) {
return () =>
h(
'div',
{
class:
`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(),
style: {
backfaceVisibility: 'hidden',
transformStyle: 'preserve-3d'
}
},
slots.default?.()
);
}
});
</script>
<template>
<div
ref="scrollerRef"
:class="['relative w-full h-full overflow-y-auto overflow-x-visible', className]"
:style="{
overscrollBehavior: 'contain',
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
WebkitTransform: 'translateZ(0)',
transform: 'translateZ(0)',
willChange: 'scroll-position'
}"
>
<div class="px-20 pt-[20vh] pb-[50rem] min-h-screen scroll-stack-inner">
<slot />
<!-- Spacer so the last pin can release cleanly -->
<div class="w-full h-px scroll-stack-end" />
</div>
</div>
</template>

View File

@@ -566,3 +566,90 @@ div:has(> .props-table) {
order: 2; order: 2;
} }
} }
.scroll-stack-card-demo {
font-size: clamp(1.5rem, 4vw, 3rem);
font-weight: 900;
color: #fff;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 1rem;
white-space: nowrap;
text-align: center;
}
.scroll-stack-card-demo .stack-img-container {
width: 100%;
height: 100%;
min-height: 150px;
border-radius: 1.5rem;
border: 10px solid #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(4rem, 8vw, 8rem);
}
.scroll-stack-demo-container .scroll-stack-inner {
padding: 20vh 2rem 50rem;
}
.ssc-demo-1 {
background-color: #5227ff;
}
.ssc-demo-2 {
background-color: #f01e9c;
}
.ssc-demo-3 {
background-color: #5227ff;
}
.ssc-demo-4 {
background-color: #f01e9c;
}
.ssc-demo-5 {
background-color: #5227ff;
text-align: center;
justify-content: center;
align-items: center;
}
@media only screen and (min-width: 1240px) {
.scroll-stack-card-demo {
flex-direction: row;
gap: 2rem;
text-align: left;
}
.scroll-stack-demo-container .scroll-stack-inner {
padding: 20vh 5rem 50rem;
}
.scroll-stack-card-demo .stack-img-container {
width: 50%;
min-height: auto;
}
.scroll-stack-card-demo h3 {
margin-bottom: 1em;
}
}
@media only screen and (max-width: 480px) {
.scroll-stack-card-demo {
font-size: 1rem;
padding: 0.2rem;
gap: 0.5rem;
}
.scroll-stack-card-demo .stack-img-container {
border-width: 5px;
border-radius: 1rem;
min-height: 120px;
font-size: 3rem;
}
}

View File

@@ -0,0 +1,216 @@
<template>
<TabbedLayout>
<template #preview>
<div class="relative h-[500px] overflow-hidden demo-container">
<RefreshButton
@refresh="
() => {
isCompleted = false;
forceRerender();
}
"
/>
<p
class="top-[25%] left-[50%] absolute font-black text-[#271e37] text-[clamp(2rem,4vw,3rem)] text-center transition-all -translate-x-1/2 -translate-y-1/2 duration-300 ease-in-out pointer-events-none transform"
>
{{ isCompleted ? 'Stack Completed!' : 'Scroll Down' }}
</p>
<ScrollStack
:key="rerenderKey"
:item-distance="itemDistance"
className="scroll-stack-demo-container"
:item-stack-distance="itemStackDistance"
:stack-position="stackPosition"
:base-scale="baseScale"
:rotation-amount="rotationAmount"
:blur-amount="blurAmount"
@stackComplete="handleStackComplete"
>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-1">
<h3>Text Animations</h3>
<div className="stack-img-container">
<i class="pi-align-left pi" style="font-size: 120px"></i>
</div>
</ScrollStackItem>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-2">
<h3>Animations</h3>
<div className="stack-img-container">
<i class="pi pi-play" style="font-size: 120px"></i>
</div>
</ScrollStackItem>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-3">
<h3>Components</h3>
<div className="stack-img-container">
<i class="pi pi-sliders-h" style="font-size: 120px"></i>
</div>
</ScrollStackItem>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-4">
<h3>Backgrounds</h3>
<div className="stack-img-container">
<i class="pi pi-image" style="font-size: 120px"></i>
</div>
</ScrollStackItem>
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-5">
<h3>All on Vue Bits!</h3>
</ScrollStackItem>
</ScrollStack>
</div>
<Customize>
<PreviewSlider title="Item Distance" v-model="itemDistance" :min="0" :max="1000" :step="10" value-unit="px" />
<PreviewSlider
title="Stack Distance"
v-model="itemStackDistance"
:min="0"
:max="40"
:step="5"
value-unit="px"
/>
<PreviewSelect title="Stack Position" v-model="stackPosition" :options="stackPositionOptions" />
<PreviewSlider title="Base Scale" v-model="baseScale" :min="0.5" :max="1.0" :step="0.05" />
<PreviewSlider title="Rotation Amount" v-model="rotationAmount" :min="0" :max="1" :step="0.1" value-unit="°" />
<PreviewSlider title="Blur Amount" v-model="blurAmount" :min="0" :max="10" :step="0.5" value-unit="px" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['lenis']" />
</template>
<template #code>
<CodeExample :code-object="scrollStack" />
</template>
<template #cli>
<CliInstallation :command="scrollStack.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import PreviewSelect from '@/components/common/PreviewSelect.vue';
import { useForceRerender } from '@/composables/useForceRerender';
import { ref } from 'vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PropTable from '../../components/common/PropTable.vue';
import RefreshButton from '../../components/common/RefreshButton.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import { scrollStack } from '../../constants/code/Components/scrollStackCode';
import ScrollStack, { ScrollStackItem } from '../../content/Components/ScrollStack/ScrollStack.vue';
const { rerenderKey, forceRerender } = useForceRerender();
const isCompleted = ref(false);
const itemDistance = ref(200);
const itemStackDistance = ref(30);
const baseScale = ref(0.85);
const rotationAmount = ref(0);
const blurAmount = ref(0);
const stackPosition = ref('20%');
const handleStackComplete = () => {
isCompleted.value = true;
};
const stackPositionOptions = [
{ value: '10%', label: '10%' },
{ value: '15%', label: '15%' },
{ value: '20%', label: '20%' },
{ value: '25%', label: '25%' },
{ value: '30%', label: '30%' },
{ value: '35%', label: '35%' }
];
const propData = [
{
name: 'children',
type: 'ReactNode',
default: 'required',
description: 'The content to be displayed in the scroll stack. Should contain ScrollStackItem components.'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional CSS classes to apply to the scroll stack container.'
},
{
name: 'itemDistance',
type: 'number',
default: '100',
description: 'Distance between stacked items in pixels.'
},
{
name: 'itemScale',
type: 'number',
default: '0.03',
description: 'Scale increment for each stacked item.'
},
{
name: 'itemStackDistance',
type: 'number',
default: '30',
description: 'Distance between items when they start stacking.'
},
{
name: 'stackPosition',
type: 'string',
default: '"20%"',
description: 'Position where the stacking effect begins as a percentage of viewport height.'
},
{
name: 'scaleEndPosition',
type: 'string',
default: '"10%"',
description: 'Position where the scaling effect ends as a percentage of viewport height.'
},
{
name: 'baseScale',
type: 'number',
default: '0.85',
description: 'Base scale value for the first item in the stack.'
},
{
name: 'scaleDuration',
type: 'number',
default: '0.5',
description: 'Duration of the scaling animation in seconds.'
},
{
name: 'rotationAmount',
type: 'number',
default: '0',
description: 'Rotation amount for each item in degrees.'
},
{
name: 'blurAmount',
type: 'number',
default: '0',
description: 'Blur amount for items that are further back in the stack.'
},
{
name: 'onStackComplete',
type: 'function',
default: 'undefined',
description: 'Callback function called when the stack animation is complete.'
}
];
</script>
<style scoped>
.demo-container {
padding: 0;
}
</style>