mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
266 lines
6.7 KiB
Vue
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>
|