wui
组件

图像拖尾 Image Trail

随着鼠标在画布或容器内移动,在指针移动轨迹上按序生成短暂停留并渐隐消失的视觉元素拖尾组件。

第三方依赖 · motion

基础用法

在画布内移动鼠标,照片沿指针轨迹以弹簧弹出、带随机角度,随后顺着移动方向淡出:

许然摄影作品集

在山里的一年

移动鼠标翻阅照片

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/image-trail
安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/image-trail.tsx
components/ui/image-trail.tsx
"use client"

import * as React from "react"
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type HTMLMotionProps,
} from "motion/react"

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

interface TrailItem {
  id: number
  itemIndex: number
  x: number
  y: number
  rotate: number
  dx: number
  dy: number
}

export interface ImageTrailProps extends Omit<
  HTMLMotionProps<"div">,
  "children"
> {
  /** Visual items cycled through as the pointer moves. */
  items: React.ReactNode[]
  /** Static content rendered inside the interaction region. */
  children?: React.ReactNode
  /** Pointer distance required before adding another item, in pixels. @default 72 */
  distance?: number
  /** Lifetime of each trail item in milliseconds. @default 720 */
  lifetime?: number
  /** Maximum number of trail items rendered at once. @default 8 */
  maxItems?: number
  /** Maximum random rotation applied to each item, in degrees. @default 8 */
  rotation?: number
  /** Classes applied to every positioned trail item. */
  itemClassName?: string
}

/** Leaves a short-lived sequence of visual items behind the pointer. */
function ImageTrail({
  items,
  children,
  distance = 72,
  lifetime = 720,
  maxItems = 8,
  rotation = 8,
  className,
  itemClassName,
  onPointerMove,
  onPointerLeave,
  ...props
}: ImageTrailProps) {
  const reduceMotion = useReducedMotion()
  const [trail, setTrail] = React.useState<TrailItem[]>([])
  const lastPosition = React.useRef<{ x: number; y: number } | null>(null)
  const sequence = React.useRef(0)
  const timers = React.useRef<Set<number>>(new Set())

  React.useEffect(() => {
    const activeTimers = timers.current
    return () => {
      activeTimers.forEach((timer) => window.clearTimeout(timer))
    }
  }, [])

  function addItem(event: React.PointerEvent<HTMLDivElement>) {
    if (reduceMotion || event.pointerType === "touch" || items.length === 0)
      return

    const rect = event.currentTarget.getBoundingClientRect()
    const x = event.clientX - rect.left
    const y = event.clientY - rect.top
    const previous = lastPosition.current
    if (previous && Math.hypot(x - previous.x, y - previous.y) < distance)
      return

    // Items drift slightly along the pointer's direction as they fade out.
    const dx = previous ? (x - previous.x) * 0.25 : 0
    const dy = previous ? (y - previous.y) * 0.25 : 0
    lastPosition.current = { x, y }
    const id = sequence.current++
    const nextItem = {
      id,
      itemIndex: id % items.length,
      x,
      y,
      rotate: (Math.random() * 2 - 1) * rotation,
      dx,
      dy,
    }
    setTrail((current) => [...current, nextItem].slice(-maxItems))

    const timer = window.setTimeout(() => {
      setTrail((current) => current.filter((item) => item.id !== id))
      timers.current.delete(timer)
    }, lifetime)
    timers.current.add(timer)
  }

  return (
    <motion.div
      data-slot="image-trail"
      className={cn("relative isolate overflow-hidden", className)}
      onPointerMove={(event) => {
        addItem(event)
        onPointerMove?.(event)
      }}
      onPointerLeave={(event) => {
        lastPosition.current = null
        onPointerLeave?.(event)
      }}
      {...props}
    >
      {children}
      <AnimatePresence>
        {trail.map((item) => (
          <motion.div
            key={item.id}
            aria-hidden="true"
            data-slot="image-trail-item"
            className={cn(
              "pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-1/2 will-change-transform",
              itemClassName
            )}
            style={{ left: item.x, top: item.y }}
            initial={{ opacity: 0, scale: 0.6, rotate: item.rotate * 1.6 }}
            animate={{
              opacity: 1,
              scale: 1,
              rotate: item.rotate,
              transition: { type: "spring", stiffness: 380, damping: 26 },
            }}
            exit={{
              opacity: 0,
              scale: 0.86,
              x: item.dx,
              y: item.dy + 12,
              transition: { duration: 0.45, ease: [0.4, 0, 0.2, 1] },
            }}
          >
            {items[item.itemIndex]}
          </motion.div>
        ))}
      </AnimatePresence>
    </motion.div>
  )
}

export { ImageTrail }

属性 Props

属性类型默认值说明
itemsReact.ReactNode[]—在移动轨迹上按顺序循环生成的视觉子项数组(图片卡片、徽章、图标等)。
childrenReact.ReactNode—静止渲染在交互区域中央或底部的静态主要内容。
distancenumber72指针移动必须跨越的最小欧式距离阈值(像素),超过该距离才会生成下一个拖尾项。
lifetimenumber720每个拖尾项在画布上停留并渐隐销毁的生命周期时间(单位:毫秒)。
maxItemsnumber8画布上允许同时存在的最大拖尾项数量上限,防止密集移动导致 DOM 过载。
rotationnumber8每个拖尾项随机旋转角度的上限(度),`0` 表示保持水平。
itemClassNamestring—应用于每个由 absolute 定位的拖尾单项的 CSS 类名。
classNamestring—应用于最外层交互容器的 CSS 类名。

事件 Events

属性类型默认值说明
onPointerMove(event: React.PointerEvent) => void—光标在容器内移动并计算拖尾距离时触发。
onPointerLeave(event: React.PointerEvent) => void—光标离开交互区域时触发,重置上一帧记录位置。

使用场景与设计规范

ImageTrail 适用于创意设计工作室主页、作品集画廊、品牌互动 Hero 展区:

  • 智能节流与垃圾回收:内置欧氏距离计算与 setTimeout 定时器清理机制,未达到移动阈值时不会产生多余渲染。
  • 轻量旋转与物理弹跳:新生成的拖尾项以弹簧入场并带随机旋转(由 rotation 控制),离场时沿指针移动方向轻微漂移。
  • 生命周期:lifetime 是拖尾项完整停留的时长,之后再播放约 0.45 秒的淡出。

场景示例

标签拖尾

拖尾项不限于图片,任意节点都可以。这里用带主题色圆点的标签,并加大 rotation:

我们擅长的事

在这里移动指针

无障碍与交互 Accessibility

  • 移动端自动静音:在触控屏幕(touch)上自动禁用拖尾生成,保证页面正常的上下滑动流畅度。
  • 动效减弱适配:当用户开启 prefers-reduced-motion: reduce 时,拖尾生成逻辑被完全旁路,仅展示静态内容。
  • 无障碍树纯净:拖尾项全部标记 aria-hidden="true" 和 pointer-events-none,不会干扰读屏与点击。