mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
Migrate ScrollVelocity component
This commit is contained in:
@@ -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'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
24
src/constants/code/TextAnimations/scrollVelocityCode.ts
Normal file
24
src/constants/code/TextAnimations/scrollVelocityCode.ts
Normal 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
|
||||||
|
};
|
||||||
236
src/content/TextAnimations/ScrollVelocity/ScrollVelocity.vue
Normal file
236
src/content/TextAnimations/ScrollVelocity/ScrollVelocity.vue
Normal 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 }}
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
|
|||||||
132
src/demo/TextAnimations/ScrollVelocityDemo.vue
Normal file
132
src/demo/TextAnimations/ScrollVelocityDemo.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user