Files
vue-bits/src/content/Components/InfiniteScroll/InfiniteScroll.vue
2025-07-10 15:36:38 +03:00

266 lines
6.7 KiB
Vue

<template>
<div class="w-full">
<div class="infinite-scroll-wrapper relative flex items-center justify-center w-full overflow-hidden"
ref="wrapperRef" :style="{
maxHeight: maxHeight,
overscrollBehavior: 'none'
}">
<div class="infinite-scroll-container flex flex-col px-4 cursor-grab" ref="containerRef" :style="{
transform: getTiltTransform(),
width: width,
overscrollBehavior: 'contain',
transformOrigin: 'center center',
transformStyle: 'preserve-3d'
}">
<div v-for="(item, index) in items" :key="index"
class="infinite-scroll-item rounded-2xl flex items-center justify-center p-4 text-xl font-semibold text-center border-2 border-white select-none box-border relative"
:style="{
height: itemMinHeight + 'px',
marginTop: negativeMargin
}">
<component :is="item.content" v-if="typeof item.content === 'object'" />
<template v-else>{{ item.content }}</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { gsap } from 'gsap'
import { Observer } from 'gsap/Observer'
gsap.registerPlugin(Observer)
interface InfiniteScrollItem {
content: string | object
}
interface Props {
width?: string
maxHeight?: string
negativeMargin?: string
items?: InfiniteScrollItem[]
itemMinHeight?: number
isTilted?: boolean
tiltDirection?: 'left' | 'right'
autoplay?: boolean
autoplaySpeed?: number
autoplayDirection?: 'down' | 'up'
pauseOnHover?: boolean
}
const props = withDefaults(defineProps<Props>(), {
width: '30rem',
maxHeight: '100%',
negativeMargin: '-0.5em',
items: () => [],
itemMinHeight: 150,
isTilted: false,
tiltDirection: 'left',
autoplay: false,
autoplaySpeed: 0.5,
autoplayDirection: 'down',
pauseOnHover: false
})
const wrapperRef = ref<HTMLDivElement | null>(null)
const containerRef = ref<HTMLDivElement | null>(null)
let observer: Observer | null = null
let rafId: number | null = null
let velocity = 0
let stopTicker: (() => void) | null = null
let startTicker: (() => void) | null = null
const getTiltTransform = (): string => {
if (!props.isTilted) return 'none'
return props.tiltDirection === 'left'
? 'rotateX(20deg) rotateZ(-20deg) skewX(20deg)'
: 'rotateX(20deg) rotateZ(20deg) skewX(-20deg)'
}
const initializeScroll = () => {
const container = containerRef.value
if (!container) return
if (props.items.length === 0) return
const divItems = gsap.utils.toArray<HTMLDivElement>(container.children)
if (!divItems.length) return
const firstItem = divItems[0]
const itemStyle = getComputedStyle(firstItem)
const itemHeight = firstItem.offsetHeight
const itemMarginTop = parseFloat(itemStyle.marginTop) || 0
const totalItemHeight = itemHeight + itemMarginTop
const totalHeight = itemHeight * props.items.length + itemMarginTop * (props.items.length - 1)
const wrapFn = gsap.utils.wrap(-totalHeight, totalHeight)
divItems.forEach((child, i) => {
const y = i * totalItemHeight
gsap.set(child, { y })
})
observer = Observer.create({
target: container,
type: 'wheel,touch,pointer',
preventDefault: true,
onPress: ({ target }) => {
; (target as HTMLElement).style.cursor = 'grabbing'
},
onRelease: ({ target }) => {
; (target as HTMLElement).style.cursor = 'grab'
if (Math.abs(velocity) > 0.1) {
const momentum = velocity * 0.8
divItems.forEach((child) => {
gsap.to(child, {
duration: 1.5,
ease: 'power2.out',
y: `+=${momentum}`,
modifiers: {
y: gsap.utils.unitize(wrapFn)
}
})
})
}
velocity = 0
},
onChange: ({ deltaY, isDragging, event }) => {
const d = event.type === 'wheel' ? -deltaY : deltaY
const distance = isDragging ? d * 5 : d * 1.5
velocity = distance * 0.5
divItems.forEach((child) => {
gsap.to(child, {
duration: isDragging ? 0.3 : 1.2,
ease: isDragging ? 'power1.out' : 'power3.out',
y: `+=${distance}`,
modifiers: {
y: gsap.utils.unitize(wrapFn)
}
})
})
}
})
if (props.autoplay) {
const directionFactor = props.autoplayDirection === 'down' ? 1 : -1
const speedPerFrame = props.autoplaySpeed * directionFactor
const tick = () => {
divItems.forEach((child) => {
gsap.set(child, {
y: `+=${speedPerFrame}`,
modifiers: {
y: gsap.utils.unitize(wrapFn)
}
})
})
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
if (props.pauseOnHover) {
stopTicker = () => rafId && cancelAnimationFrame(rafId)
startTicker = () => {
rafId = requestAnimationFrame(tick)
}
container.addEventListener('mouseenter', stopTicker)
container.addEventListener('mouseleave', startTicker)
}
}
}
const cleanup = () => {
if (observer) {
observer.kill()
observer = null
}
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
velocity = 0
const container = containerRef.value
if (container && props.pauseOnHover && stopTicker && startTicker) {
container.removeEventListener('mouseenter', stopTicker)
container.removeEventListener('mouseleave', startTicker)
}
stopTicker = null
startTicker = null
}
onMounted(() => {
initializeScroll()
})
onUnmounted(() => {
cleanup()
})
watch(
[
() => props.items,
() => props.autoplay,
() => props.autoplaySpeed,
() => props.autoplayDirection,
() => props.pauseOnHover,
() => props.isTilted,
() => props.tiltDirection,
() => props.negativeMargin
],
() => {
cleanup()
setTimeout(() => {
initializeScroll()
}, 0)
}
)
</script>
<style scoped>
.infinite-scroll-wrapper::before,
.infinite-scroll-wrapper::after {
content: "";
position: absolute;
background: linear-gradient(var(--dir, to bottom), #060010, transparent);
height: 25%;
width: 100%;
z-index: 1;
pointer-events: none;
}
.infinite-scroll-wrapper::before {
top: 0;
}
.infinite-scroll-wrapper::after {
--dir: to top;
bottom: 0;
}
.infinite-scroll-container {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-ms-backface-visibility: hidden;
}
.infinite-scroll-item {
--accent-color: #ffffff;
border-color: var(--accent-color);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-ms-backface-visibility: hidden;
transform: translateZ(0);
}
</style>