组件
无限轮播 Infinite Slider
沿着水平或垂直轴无缝无间断循环滚动的连续内容展示轨道,支持悬停变速与暂停。
基础用法
水平方向无缝循环滚动的技术栈标志流:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/infinite-slider安装基础依赖与动效库
pnpm add motion class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/infinite-slider.tsx"use client"
import * as React from "react"
import {
motion,
useAnimationFrame,
useInView,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
export interface InfiniteSliderProps extends Omit<
HTMLMotionProps<"div">,
"children" | "direction"
> {
/** Items displayed in the continuously looping track. */
children: React.ReactNode
/** Space between items in pixels. @default 16 */
gap?: number
/** Track speed in pixels per second. @default 64 */
speed?: number
/** Track speed while hovered. Set to `0` to pause on hover. */
speedOnHover?: number
/** Slider axis. @default "horizontal" */
direction?: "horizontal" | "vertical"
/** Move in the opposite direction. @default false */
reverse?: boolean
}
/** A continuously looping horizontal or vertical content track. */
function InfiniteSlider({
children,
className,
gap = 16,
speed = 64,
speedOnHover,
direction = "horizontal",
reverse = false,
onMouseEnter,
onMouseLeave,
...props
}: InfiniteSliderProps) {
const containerRef = React.useRef<HTMLDivElement>(null)
const trackRef = React.useRef<HTMLDivElement>(null)
const groupRef = React.useRef<HTMLDivElement>(null)
const progressRef = React.useRef(0)
const hoveredRef = React.useRef(false)
const currentSpeedRef = React.useRef(speed)
const [groupSize, setGroupSize] = React.useState(0)
const reduceMotion = useReducedMotion()
const inView = useInView(containerRef)
// Switch to the static, scrollable layout only after hydration so the
// server and client markup always match.
const [staticLayout, setStaticLayout] = React.useState(false)
React.useEffect(() => {
setStaticLayout(Boolean(reduceMotion))
if (reduceMotion && trackRef.current) trackRef.current.style.transform = ""
}, [reduceMotion])
React.useEffect(() => {
const group = groupRef.current
if (!group) return
const measure = () => {
const rect = group.getBoundingClientRect()
setGroupSize(direction === "horizontal" ? rect.width : rect.height)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(group)
return () => observer.disconnect()
}, [direction])
useAnimationFrame((_, delta) => {
const track = trackRef.current
if (staticLayout || !inView || groupSize === 0 || !track) return
// Ease toward the target speed so hover slow-downs and pauses glide
// instead of snapping.
const targetSpeed =
hoveredRef.current && speedOnHover !== undefined ? speedOnHover : speed
const smoothing = 1 - Math.exp(-delta / 180)
currentSpeedRef.current +=
(targetSpeed - currentSpeedRef.current) * smoothing
const next = progressRef.current + (currentSpeedRef.current * delta) / 1000
progressRef.current = ((next % groupSize) + groupSize) % groupSize
const progress = progressRef.current
const offset = reverse ? progress - groupSize : -progress
track.style.transform =
direction === "horizontal"
? `translate3d(${offset}px, 0, 0)`
: `translate3d(0, ${offset}px, 0)`
})
const groupClassName = cn(
"flex shrink-0",
direction === "horizontal" ? "flex-row" : "flex-col"
)
const groupStyle =
direction === "horizontal"
? { gap, paddingRight: gap }
: { gap, paddingBottom: gap }
return (
<motion.div
ref={containerRef}
data-slot="infinite-slider"
className={cn(
staticLayout
? direction === "horizontal"
? "overflow-x-auto"
: "overflow-y-auto"
: "overflow-hidden",
className
)}
onMouseEnter={(event) => {
hoveredRef.current = true
onMouseEnter?.(event)
}}
onMouseLeave={(event) => {
hoveredRef.current = false
onMouseLeave?.(event)
}}
{...props}
>
<div
ref={trackRef}
data-slot="infinite-slider-track"
className={cn(
"flex w-max will-change-transform",
direction === "vertical" && "flex-col"
)}
>
<div
ref={groupRef}
data-slot="infinite-slider-group"
className={groupClassName}
style={groupStyle}
>
{children}
</div>
{staticLayout ? null : (
<div
aria-hidden="true"
inert
data-slot="infinite-slider-group"
className={groupClassName}
style={groupStyle}
>
{children}
</div>
)}
</div>
</motion.div>
)
}
export { InfiniteSlider }
属性 Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 在无限循环轨道中展示的子节点元素列表(如品牌 Logo、客户评价、商品卡片等)。 |
| gap | number | 16 | 循环子元素之间的间距(单位:像素)。 |
| speed | number | 64 | 常规滚动时的线速度(单位:像素 / 秒)。 |
| speedOnHover | number | — | 鼠标悬停在轨道上方时的线速度。设置为 0 可实现悬停暂停;速度切换会平滑缓动,而不是瞬间急停。 |
| direction | "horizontal" | "vertical" | "horizontal" | 滚动的物理主轴方向。 |
| reverse | boolean | false | 是否沿反向滚动(水平模式下从左至右,垂直模式下从上至下)。 |
| className | string | — | 应用于外层视口容器的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onMouseEnter | (event: React.MouseEvent) => void | — | 鼠标进入滚动区域时触发(若配置了 speedOnHover 则自动应用悬停速度)。 |
| onMouseLeave | (event: React.MouseEvent) => void | — | 鼠标离开滚动区域时触发,恢复默认 speed 滚动速度。 |
使用场景与设计规范
InfiniteSlider 适用于合作伙伴 Logo 墙、客户真实证言推荐流、热门标签跑马灯:
- 悬停暂停交互:当展示用户评价卡片或包含可点击链接的项目时,强烈推荐配置
speedOnHover={0}或更慢的慢速,方便用户阅读并进行精准点击。 - 渐变遮罩边缘(Masking):配合 Tailwind 的
[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]可以实现边缘渐隐淡出,极大提升视觉平滑度。 - 双组无缝克隆:组件内部自动使用
ResizeObserver测量第一组内容的实际几何尺寸,并生成第二组不可聚焦(inert)的副本以保证无限循环无卡顿。
场景示例
垂直方向滚动
适用于展示实时审计事件、交易通知流或侧边栏微型动态:
Loading…
客户评价与悬停即停
鼠标悬停在评价卡片上时速度平滑减到 0,方便用户详细阅读内容:
Loading…
无障碍与交互 Accessibility
- 克隆层惰性隔离(inert):复制生成的第二组轮播内容自动被标记
aria-hidden="true"和inert,防止键盘焦点与屏幕阅读器在无尽的重复项中循环迷失。 - 动效减弱降级(prefers-reduced-motion):当系统启用减少动效时,组件会在水合完成后停止 requestAnimationFrame 动画循环,并退化为原生可横向/纵向自由滚动的滚动条容器(
overflow: auto),服务端与客户端首帧 HTML 保持一致。