Vue3 无限平滑滚动组件
这是一款开箱即用的 Vue3 无限平滑滚动组件,支持四个方向(左、右、上、下)自由切换,具有良好的可配置性。组件默认开启鼠标悬停暂停和滚轮滚动交互,滚动过程自然平滑,可用于新闻轮播、公告栏、商品展示等场景。通过 slidesPerView
控制一次显示的条目数,itemGap
控制每项之间的间距,支持自动复制内容避免滚动断层。使用简单,插槽式内容传入,代码结构清晰,适合二次封装或项目直接使用。本文贴出完整实现代码,拷贝即可使用。
先看效果
(gif不清晰,大概看看是这么个意思)

话不多说,直接贴代码
xml
<template>
<div class="scroll-wrapper" ref="wrapper">
<div class="scroll-content" ref="scrollWrapper">
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
const props = defineProps({
direction: {
type: String,
default: 'left',
validator: (val) => ['left', 'right', 'up', 'down'].includes(val),
},
step: {
type: Number,
default: 1, // 每帧滚动多少像素
},
pauseOnHover: {
type: Boolean,
default: true,
},
scrollOnWheel: {
type: Boolean,
default: true, //监听鼠标滚动的事件
},
slidesPerView: {
type: Number,
default: 1, // 每次显示几条
},
itemGap: {
type: Number,
default: 2, // 每项之间的间距(单位 px)
},
})
const wrapper = ref(null)
const scrollWrapper = ref(null)
let isHovering = false
let scrollAmount = 0
let animationFrameId = 0
const COPYNUM = 2
// copy一份内容,防止循环一半切换时视觉空白
const duplicateContent = () => {
const originalChildren = Array.from(scrollWrapper.value.children)
// 先清空可能存在的克隆元素
const clones = scrollWrapper.value.querySelectorAll('.cloned')
clones.forEach((clone) => clone.remove())
// 复制内容并插入到前面
originalChildren.forEach((child) => {
const clone = child.cloneNode(true)
clone.classList.add('cloned')
scrollWrapper.value.appendChild(clone)
})
}
// 边界情况处理,尤其在鼠标滚动的时候
const resetIfOutOfBounds = () => {
const sw = scrollWrapper.value
const horizontal = ['left', 'right'].includes(props.direction)
const scrollSize = horizontal ? sw.scrollWidth : sw.scrollHeight
const clientSize = horizontal ? sw.clientWidth : sw.clientHeight
const halfLimit = scrollSize / COPYNUM
const maxScroll = scrollSize - clientSize
if (['left', 'up'].includes(props.direction)) {
if (scrollAmount >= halfLimit || scrollAmount <= 0) {
scrollAmount = 0
}
} else {
const minScroll = maxScroll - halfLimit
if (scrollAmount >= maxScroll || scrollAmount <= minScroll) {
scrollAmount = maxScroll
}
}
}
const scroll = () => {
if (!(props.pauseOnHover && isHovering)) {
if (['left', 'up'].includes(props.direction)) {
scrollAmount += props.step
} else {
scrollAmount -= props.step
}
}
if (props.direction === 'left' || props.direction === 'right') {
scrollWrapper.value.style.transform = `translateX(${-scrollAmount}px)`
} else {
scrollWrapper.value.style.transform = `translateY(${-scrollAmount}px)`
}
resetIfOutOfBounds()
animationFrameId = requestAnimationFrame(scroll)
}
const onMouseEnter = () => (isHovering = true)
const onMouseLeave = () => (isHovering = false)
const onWheel = (e) => {
if (!props.scrollOnWheel) return
e.preventDefault()
const delta = e.deltaY || e.deltaX
scrollAmount += delta
}
const setItemSize = () => {
const wrapperSize =
props.direction === 'left' || props.direction === 'right'
? wrapper.value.clientWidth
: wrapper.value.clientHeight
const itemSize = (wrapperSize - props.itemGap * (props.slidesPerView - 1)) / props.slidesPerView
const isVertical = ['up', 'down'].includes(props.direction)
scrollWrapper.value.style.flexDirection = isVertical ? 'column' : 'row'
scrollWrapper.value.style.gap = props.itemGap + 'px'
const items = Array.from(scrollWrapper.value.children).filter(
(el) => !el.classList.contains('cloned'),
)
Array.from(items).forEach((el) => {
if (isVertical) {
el.style.height = itemSize + 'px'
} else {
el.style.width = itemSize + 'px'
}
})
}
const setInitialScrollAmount = () => {
if (props.direction === 'left') {
scrollAmount = 0
} else if (props.direction === 'up') {
scrollAmount = 0
} else if (props.direction === 'down') {
scrollAmount = scrollWrapper.value.scrollHeight - scrollWrapper.value.clientHeight
} else if (props.direction === 'right') {
scrollAmount = scrollWrapper.value.scrollWidth - scrollWrapper.value.clientWidth
}
}
onMounted(() => {
nextTick(() => {
setItemSize()
duplicateContent()
setInitialScrollAmount()
wrapper.value.addEventListener('mouseenter', onMouseEnter)
wrapper.value.addEventListener('mouseleave', onMouseLeave)
if (props.scrollOnWheel) {
wrapper.value.addEventListener('wheel', onWheel, { passive: false })
}
animationFrameId = requestAnimationFrame(scroll)
})
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
wrapper.value.removeEventListener('mouseenter', onMouseEnter)
wrapper.value.removeEventListener('mouseleave', onMouseLeave)
if (props.scrollOnWheel) {
wrapper.value.removeEventListener('wheel', onWheel)
}
})
</script>
<style scoped>
.scroll-wrapper {
overflow: hidden;
width: 100%;
height: 100%;
}
.scroll-content {
display: flex;
width: 100%;
height: 100%;
}
.scroll-content > * {
flex-shrink: 0;
}
</style>
使用
ini
<SeamlessScroll direction="up" :slidesPerView="4">
<div class="upload-item" v-for="(item, index) in uploadList" :key="index">
<div class="left-wrap">
<SvgIcon
name="pdf"
:color="statusColorMap[item.status]?.color || '#157EFB'"
size="23px"
class="pdf-icon"
/>
<p class="title">{{ item.title }}</p>
</div>
<span>{{ item.size }}</span>
<span>{{ item.time }}</span>
<p class="status" :style="{ color: statusColorMap[item.status]?.color }">
{{ item.statusText }}
</p>
</div>
</SeamlessScroll>