组件
滚动速度跑马灯 Velocity Marquee
跑马灯根据滚动速度加速,并随滚动方向反转。
基础用法
在区域内上下滚动:滚得越快,文字跑得越快,并随滚动方向掉头;skew 让高速时的文字产生轻微倾斜。
Loading…
pnpm dlx @wui-design/cli@latest add @wui/velocity-marquee"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 | — | 在无缝轨道中重复显示的内容。 |
| baseSpeed | number | 36 | 静止滚动时轨道的速度,单位为每秒像素数。 |
| sensitivity | number | 0.08 | 滚动速度每增加一个单位时,轨道额外增加的速度。 |
| maxBoost | number | 180 | 由滚动速度带来的最大加速值,单位为每秒像素数。 |
| gap | number | 32 | 重复内容组之间的间距。 |
| reverse | boolean | false | 是否反转基础滚动方向和跟随滚动的方向。 |
| skew | number | 0 | 快速滚动时的最大倾斜角度,单位为度,设为 0 时关闭。 |
| pauseOnHover | boolean | false | 鼠标悬停时是否让轨道缓慢停下。 |
| container | RefObject<HTMLElement | null> | — | 要监听的滚动容器;未设置时监听页面。 |
| groupClassName | string | — | 应用于每个重复内容组的 CSS 类名。 |
属性名后的 * 表示必填。
使用说明
sensitivity 决定滚动反馈强度,maxBoost 防止触控板快速滑动造成不可读的速度。skew 只在滚动时随速度出现,静止时归零;整体斜置的构图应在组件外通过小角度 rotate 完成。
组件离开视口或用户开启“减少动态效果”时会停止动画;后者回退为可横向滚动内容。