Migrate ScrollVelocity component

This commit is contained in:
David Haz
2025-07-18 15:11:59 +03:00
parent 94c4cdd704
commit 20e76e75cf
6 changed files with 397 additions and 2 deletions

View File

@@ -25,7 +25,8 @@ export const CATEGORIES = [
'Scroll Float', 'Scroll Float',
'Scroll Reveal', 'Scroll Reveal',
'Rotating Text', 'Rotating Text',
'Glitch Text' 'Glitch Text',
'Scroll Velocity'
] ]
}, },
{ {

View File

@@ -37,6 +37,7 @@ const textAnimations = {
'scroll-reveal': () => import("../demo/TextAnimations/ScrollRevealDemo.vue"), 'scroll-reveal': () => import("../demo/TextAnimations/ScrollRevealDemo.vue"),
'rotating-text': () => import("../demo/TextAnimations/RotatingTextDemo.vue"), 'rotating-text': () => import("../demo/TextAnimations/RotatingTextDemo.vue"),
'glitch-text': () => import("../demo/TextAnimations/GlitchTextDemo.vue"), 'glitch-text': () => import("../demo/TextAnimations/GlitchTextDemo.vue"),
'scroll-velocity': () => import("../demo/TextAnimations/ScrollVelocityDemo.vue"),
}; };
const components = { const components = {

View File

@@ -0,0 +1,24 @@
import code from '@content/TextAnimations/ScrollVelocity/ScrollVelocity.vue?raw';
import type { CodeObject } from '../../../types/code';
export const scrollVelocity: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/ScrollVelocity`,
installation: `npm install gsap`,
usage: `<template>
<ScrollVelocity
:texts="['Vue Bits', 'Scroll Down']"
:velocity="100"
:damping="50"
:stiffness="400"
:velocity-mapping="{ input: [0, 1000], output: [0, 5] }"
class-name="custom-scroll-text"
parallax-class-name="custom-parallax"
scroller-class-name="custom-scroller"
/>
</template>
<script setup lang="ts">
import ScrollVelocity from "./ScrollVelocity.vue";
</script>`,
code
};

View File

@@ -0,0 +1,236 @@
<template>
<section>
<div
v-for="(text, index) in texts"
:key="index"
ref="containerRef"
:class="`${parallaxClassName} relative overflow-hidden`"
:style="parallaxStyle"
>
<div
ref="scrollerRef"
:class="`${scrollerClassName} flex whitespace-nowrap text-center font-sans text-4xl font-bold tracking-[-0.02em] drop-shadow md:text-[5rem] md:leading-[5rem]`"
:style="{ transform: `translateX(${scrollTransforms[index] || '0px'})`, ...scrollerStyle }"
>
<span
v-for="spanIndex in calculatedCopies[index] || 15"
:key="spanIndex"
:class="`flex-shrink-0 ${className}`"
:ref="spanIndex === 1 ? el => setCopyRef(el, index) : undefined"
>
{{ text }}&nbsp;
</span>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, type ComponentPublicInstance } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
interface VelocityMapping {
input: [number, number];
output: [number, number];
}
interface ScrollVelocityProps {
scrollContainerRef?: HTMLElement | null;
texts?: string[];
velocity?: number;
className?: string;
damping?: number;
stiffness?: number;
velocityMapping?: VelocityMapping;
parallaxClassName?: string;
scrollerClassName?: string;
parallaxStyle?: Record<string, string | number>;
scrollerStyle?: Record<string, string | number>;
}
const props = withDefaults(defineProps<ScrollVelocityProps>(), {
texts: () => [],
velocity: 100,
className: '',
damping: 50,
stiffness: 400,
velocityMapping: () => ({ input: [0, 1000], output: [0, 5] }),
parallaxClassName: '',
scrollerClassName: '',
parallaxStyle: () => ({}),
scrollerStyle: () => ({})
});
const containerRef = ref<HTMLDivElement[]>([]);
const scrollerRef = ref<HTMLDivElement[]>([]);
const copyRefs = ref<HTMLSpanElement[]>([]);
const baseX = ref<number[]>([]);
const scrollVelocity = ref(0);
const smoothVelocity = ref(0);
const velocityFactor = ref(0);
const copyWidths = ref<number[]>([]);
const directionFactors = ref<number[]>([]);
const calculatedCopies = ref<number[]>([]);
let rafId: number | null = null;
let scrollTriggerInstance: ScrollTrigger | null = null;
let lastScrollY = 0;
let lastTime = 0;
let resizeTimeout: number | null = null;
const setCopyRef = (el: Element | ComponentPublicInstance | null, index: number) => {
if (el && el instanceof HTMLSpanElement) {
copyRefs.value[index] = el;
}
};
const updateWidths = () => {
props.texts.forEach((_, index) => {
if (copyRefs.value[index] && containerRef.value[index]) {
const singleCopyWidth = copyRefs.value[index].offsetWidth;
const containerWidth = containerRef.value[index].offsetWidth;
const viewportWidth = window.innerWidth;
const effectiveWidth = Math.max(containerWidth, viewportWidth);
const minCopies = Math.ceil((effectiveWidth * 2.5) / singleCopyWidth);
const optimalCopies = Math.max(minCopies, 8);
copyWidths.value[index] = singleCopyWidth;
calculatedCopies.value[index] = optimalCopies;
}
});
};
const debouncedUpdateWidths = () => {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = window.setTimeout(() => {
updateWidths();
resizeTimeout = null;
}, 150);
};
const wrap = (min: number, max: number, v: number): number => {
const range = max - min;
if (range === 0) return min;
const mod = (((v - min) % range) + range) % range;
return mod + min;
};
const scrollTransforms = computed(() => {
return props.texts.map((_, index) => {
const singleWidth = copyWidths.value[index];
if (singleWidth === 0) return '0px';
return `${wrap(-singleWidth, 0, baseX.value[index] || 0)}px`;
});
});
const updateSmoothVelocity = () => {
const dampingFactor = props.damping / 1000;
const stiffnessFactor = props.stiffness / 1000;
const velocityDiff = scrollVelocity.value - smoothVelocity.value;
smoothVelocity.value += velocityDiff * stiffnessFactor;
smoothVelocity.value *= 1 - dampingFactor;
};
const updateVelocityFactor = () => {
const { input, output } = props.velocityMapping;
const inputRange = input[1] - input[0];
const outputRange = output[1] - output[0];
let normalizedVelocity = (Math.abs(smoothVelocity.value) - input[0]) / inputRange;
normalizedVelocity = Math.max(0, Math.min(1, normalizedVelocity));
velocityFactor.value = output[0] + normalizedVelocity * outputRange;
if (smoothVelocity.value < 0) velocityFactor.value *= -1;
};
const animate = (currentTime: number) => {
if (lastTime === 0) lastTime = currentTime;
const delta = currentTime - lastTime;
lastTime = currentTime;
updateSmoothVelocity();
updateVelocityFactor();
props.texts.forEach((_, index) => {
const baseVelocity = index % 2 !== 0 ? -props.velocity : props.velocity;
let moveBy = directionFactors.value[index] * baseVelocity * (delta / 1000);
if (velocityFactor.value < 0) {
directionFactors.value[index] = -1;
} else if (velocityFactor.value > 0) {
directionFactors.value[index] = 1;
}
moveBy += directionFactors.value[index] * moveBy * velocityFactor.value;
baseX.value[index] = (baseX.value[index] || 0) + moveBy;
});
rafId = requestAnimationFrame(animate);
};
const updateScrollVelocity = () => {
const container = props.scrollContainerRef || window;
const currentScrollY = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
const currentTime = performance.now();
const timeDelta = currentTime - lastTime;
if (timeDelta > 0) {
const scrollDelta = currentScrollY - lastScrollY;
scrollVelocity.value = (scrollDelta / timeDelta) * 1000;
}
lastScrollY = currentScrollY;
};
onMounted(async () => {
await nextTick();
baseX.value = new Array(props.texts.length).fill(0);
copyWidths.value = new Array(props.texts.length).fill(0);
calculatedCopies.value = new Array(props.texts.length).fill(15);
directionFactors.value = new Array(props.texts.length).fill(1);
setTimeout(() => {
updateWidths();
}, 100);
updateWidths();
if (containerRef.value && containerRef.value.length > 0) {
scrollTriggerInstance = ScrollTrigger.create({
trigger: containerRef.value[0],
start: 'top bottom',
end: 'bottom top',
onUpdate: updateScrollVelocity,
...(props.scrollContainerRef && { scroller: props.scrollContainerRef })
});
}
rafId = requestAnimationFrame(animate);
window.addEventListener('resize', debouncedUpdateWidths, { passive: true });
});
onUnmounted(() => {
if (rafId) {
cancelAnimationFrame(rafId);
}
if (scrollTriggerInstance) {
scrollTriggerInstance.kill();
}
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
window.removeEventListener('resize', debouncedUpdateWidths);
});
</script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<TabbedLayout> <TabbedLayout>
<template #preview> <template #preview>
<div class="demo-container relative overflow-hidden py-6"> <div class="demo-container relative overflow-hidden">
<ChromaGrid /> <ChromaGrid />
</div> </div>
@@ -72,5 +72,6 @@ const propData = [
<style scoped> <style scoped>
.demo-container { .demo-container {
height: auto; height: auto;
padding: 4em 0;
} }
</style> </style>

View File

@@ -0,0 +1,132 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container overflow-hidden">
<div class="relative flex justify-center items-center">
<ScrollVelocity
:texts="['Vue Bits', 'Scroll Down']"
:velocity="velocity"
:damping="damping"
:stiffness="stiffness"
:velocity-mapping="velocityMapping"
class-name="custom-scroll-text"
/>
</div>
</div>
<Customize>
<PreviewSlider title="Velocity" v-model="velocity" :min="10" :max="500" :step="10" />
<PreviewSlider title="Damping" v-model="damping" :min="10" :max="100" :step="10" />
<PreviewSlider title="Stiffness" v-model="stiffness" :min="100" :max="1000" :step="50" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['gsap']" />
</template>
<template #code>
<CodeExample :code-object="scrollVelocity" />
</template>
<template #cli>
<CliInstallation :command="scrollVelocity.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import PropTable from '../../components/common/PropTable.vue';
import Dependencies from '../../components/code/Dependencies.vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import ScrollVelocity from '../../content/TextAnimations/ScrollVelocity/ScrollVelocity.vue';
import { scrollVelocity } from '@/constants/code/TextAnimations/scrollVelocityCode';
const velocity = ref(100);
const damping = ref(50);
const stiffness = ref(400);
const velocityMapping = ref<{ input: [number, number]; output: [number, number] }>({
input: [0, 1000],
output: [0, 5]
});
const propData = [
{
name: 'scrollContainerRef',
type: 'HTMLElement | null',
default: 'null',
description: 'Optional ref for a custom scroll container to track scroll position.'
},
{
name: 'texts',
type: 'string[]',
default: '[]',
description: 'Array of strings to display as scrolling text.'
},
{
name: 'velocity',
type: 'number',
default: '100',
description: 'Base velocity for scrolling; sign is flipped for odd indexed texts.'
},
{
name: 'className',
type: 'string',
default: '""',
description: 'CSS class applied to each text copy (span).'
},
{
name: 'damping',
type: 'number',
default: '50',
description: 'Damping value for the spring animation.'
},
{
name: 'stiffness',
type: 'number',
default: '400',
description: 'Stiffness value for the spring animation.'
},
{
name: 'velocityMapping',
type: '{ input: [number, number]; output: [number, number] }',
default: '{ input: [0, 1000], output: [0, 5] }',
description: 'Mapping from scroll velocity to a movement multiplier for dynamic scrolling.'
},
{
name: 'parallaxClassName',
type: 'string',
default: '""',
description: 'CSS class for the parallax container.'
},
{
name: 'scrollerClassName',
type: 'string',
default: '""',
description: 'CSS class for the scroller container.'
},
{
name: 'parallaxStyle',
type: 'Record<string, string | number>',
default: '{}',
description: 'Inline styles for the parallax container.'
},
{
name: 'scrollerStyle',
type: 'Record<string, string | number>',
default: '{}',
description: 'Inline styles for the scroller container.'
}
];
</script>
<style scoped>
.demo-container {
min-height: 400px;
}
</style>