wui
组件

滚动速度跑马灯 Velocity Marquee

跑马灯根据滚动速度加速,并随滚动方向反转。

第三方依赖 · motion

基础用法

在区域内上下滚动:滚得越快,文字跑得越快,并随滚动方向掉头;skew 让高速时的文字产生轻微倾斜。

Loading…
pnpm dlx @wui-design/cli@latest add @wui/velocity-marquee
components/ui/velocity-marquee.tsx
"use client"

import * as React from "react"
import {
  motion,
  useAnimationFrame,
  useInView,
  useReducedMotion,
  useScroll,
  useSpring,
  useVelocity,
  type HTMLMotionProps,
} from "motion/react"

import { cn } from "@/lib/utils"

export interface VelocityMarqueeProps extends Omit<
  HTMLMotionProps<"div">,
  "children"
> {
  /** Items repeated across the seamless track. */
  children: React.ReactNode
  /** Idle track speed in pixels per second. @default 36 */
  baseSpeed?: number
  /** Added speed for each pixel per second of scroll velocity. @default 0.08 */
  sensitivity?: number
  /** Maximum velocity-driven speed boost in pixels per second. @default 180 */
  maxBoost?: number
  /** Gap between repeated groups in pixels. @default 32 */
  gap?: number
  /** Invert the base and scroll-driven direction. @default false */
  reverse?: boolean
  /** Maximum skew in degrees applied while scrolling fast. `0` disables it. @default 0 */
  skew?: number
  /** Ease the track to a stop while the pointer is over it. @default false */
  pauseOnHover?: boolean
  /** Scrollable element to observe instead of the page. */
  container?: React.RefObject<HTMLElement | null>
  /** Classes applied to each repeated group. */
  groupClassName?: string
}

function wrap(value: number, size: number) {
  return ((value % size) + size) % size
}

/** A seamless marquee that accelerates and changes direction with scrolling. */
function VelocityMarquee({
  children,
  baseSpeed = 36,
  sensitivity = 0.08,
  maxBoost = 180,
  gap = 32,
  reverse = false,
  skew = 0,
  pauseOnHover = false,
  container,
  className,
  groupClassName,
  onPointerEnter,
  onPointerLeave,
  ...props
}: VelocityMarqueeProps) {
  const rootRef = React.useRef<HTMLDivElement>(null)
  const groupRef = React.useRef<HTMLDivElement>(null)
  const trackRef = React.useRef<HTMLDivElement>(null)
  const positionRef = React.useRef(0)
  const directionRef = React.useRef(reverse ? -1 : 1)
  const hoveredRef = React.useRef(false)
  const speedFactorRef = React.useRef(1)
  const [groupSize, setGroupSize] = React.useState(0)
  const reduceMotion = useReducedMotion()
  const inView = useInView(rootRef)
  const { scrollY } = useScroll({ container })
  const scrollVelocity = useSpring(useVelocity(scrollY), {
    stiffness: 120,
    damping: 30,
    mass: 0.35,
  })

  React.useLayoutEffect(() => {
    const group = groupRef.current
    if (!group) return

    // Layout size from ResizeObserver ignores the skew transform on the track.
    const observer = new ResizeObserver(([entry]) => {
      setGroupSize(entry.borderBoxSize[0].inlineSize)
    })
    observer.observe(group)
    return () => observer.disconnect()
  }, [])

  React.useEffect(() => {
    directionRef.current = reverse ? -1 : 1
  }, [reverse])

  useAnimationFrame((_, delta) => {
    if (reduceMotion || !inView || groupSize === 0) return

    // Clamp long frames (tab switches) so the track never teleports.
    const seconds = Math.min(delta, 64) / 1000
    const velocity = scrollVelocity.get()
    if (Math.abs(velocity) > 4) {
      directionRef.current = (velocity < 0 ? -1 : 1) * (reverse ? -1 : 1)
    }

    const targetFactor = pauseOnHover && hoveredRef.current ? 0 : 1
    speedFactorRef.current +=
      (targetFactor - speedFactorRef.current) * Math.min(seconds * 5, 1)

    const boost = Math.min(Math.abs(velocity) * sensitivity, maxBoost)
    positionRef.current +=
      directionRef.current *
      (baseSpeed + boost) *
      speedFactorRef.current *
      seconds

    if (trackRef.current) {
      const offset = -wrap(positionRef.current, groupSize)
      const lean = skew
        ? Math.max(-1, Math.min(1, velocity / 2400)) *
          skew *
          (reverse ? -1 : 1)
        : 0
      trackRef.current.style.transform = `translate3d(${offset}px, 0, 0) skewX(${-lean}deg)`
    }
  })

  const groupClasses = cn("flex shrink-0 items-center", groupClassName)
  const groupStyle = { gap, paddingRight: gap }

  return (
    <motion.div
      ref={rootRef}
      data-slot="velocity-marquee"
      className={cn(
        reduceMotion ? "overflow-x-auto" : "overflow-hidden",
        className
      )}
      onPointerEnter={(event) => {
        if (event.pointerType !== "touch") hoveredRef.current = true
        onPointerEnter?.(event)
      }}
      onPointerLeave={(event) => {
        hoveredRef.current = false
        onPointerLeave?.(event)
      }}
      {...props}
    >
      <div
        ref={trackRef}
        data-slot="velocity-marquee-track"
        className="flex w-max will-change-transform"
      >
        <div
          ref={groupRef}
          data-slot="velocity-marquee-group"
          className={groupClasses}
          style={groupStyle}
        >
          {children}
        </div>
        {reduceMotion ? null : (
          <div
            aria-hidden="true"
            inert
            data-slot="velocity-marquee-group"
            className={groupClasses}
            style={groupStyle}
          >
            {children}
          </div>
        )}
      </div>
    </motion.div>
  )
}

export { VelocityMarquee }

使用场景

VelocityMarquee 保留持续的基础速度,同时读取页面或指定容器的滚动速度来增加推进量和改变方向。 内部只渲染两个无缝内容组,第二组对辅助技术隐藏,适合展示装饰性标题、品牌词或短列表。

客户墙

不传 container 时监听整页滚动。开启 pauseOnHover 后,指针悬停会让轨道平滑减速至停止,便于辨认:

Loading…

属性

属性类型默认值说明
children *ReactNode—在无缝轨道中重复显示的内容。
baseSpeednumber36静止滚动时轨道的速度,单位为每秒像素数。
sensitivitynumber0.08滚动速度每增加一个单位时,轨道额外增加的速度。
maxBoostnumber180由滚动速度带来的最大加速值,单位为每秒像素数。
gapnumber32重复内容组之间的间距。
reversebooleanfalse是否反转基础滚动方向和跟随滚动的方向。
skewnumber0快速滚动时的最大倾斜角度,单位为度,设为 0 时关闭。
pauseOnHoverbooleanfalse鼠标悬停时是否让轨道缓慢停下。
containerRefObject<HTMLElement | null>—要监听的滚动容器;未设置时监听页面。
groupClassNamestring—应用于每个重复内容组的 CSS 类名。

属性名后的 * 表示必填。

使用说明

sensitivity 决定滚动反馈强度,maxBoost 防止触控板快速滑动造成不可读的速度。skew 只在滚动时随速度出现,静止时归零;整体斜置的构图应在组件外通过小角度 rotate 完成。 组件离开视口或用户开启“减少动态效果”时会停止动画;后者回退为可横向滚动内容。