wui
组件

无限轮播 Infinite Slider

沿着水平或垂直轴无缝无间断循环滚动的连续内容展示轨道,支持悬停变速与暂停。

第三方依赖 · motion

基础用法

水平方向无缝循环滚动的技术栈标志流:

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
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

属性类型默认值说明
childrenReact.ReactNode—在无限循环轨道中展示的子节点元素列表(如品牌 Logo、客户评价、商品卡片等)。
gapnumber16循环子元素之间的间距(单位:像素)。
speednumber64常规滚动时的线速度(单位:像素 / 秒)。
speedOnHovernumber—鼠标悬停在轨道上方时的线速度。设置为 0 可实现悬停暂停;速度切换会平滑缓动,而不是瞬间急停。
direction"horizontal" | "vertical""horizontal"滚动的物理主轴方向。
reversebooleanfalse是否沿反向滚动(水平模式下从左至右,垂直模式下从上至下)。
classNamestring—应用于外层视口容器的额外 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 保持一致。