mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 08:29:30 -06:00
Component Boom
This commit is contained in:
265
src/content/Components/InfiniteScroll/InfiniteScroll.vue
Normal file
265
src/content/Components/InfiniteScroll/InfiniteScroll.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user