丝带光标拖尾

指针身后留下一条平滑的丝带,在半秒内逐渐变细、消失。

  • Canvas
  • 光标
  • 跟随指针
  • 收录于
用指针画画看

AI 提示词

用覆盖整个容器的 <canvas> 为 React 制作光标拖尾。保存最近的指针坐标及时间戳(pointermove 时加入,丢弃超过 450ms 的点)。每次 requestAnimationFrame 时清空画布,用经过中点的 quadraticCurveTo 把这些点连成平滑的线段;按每段的时间远近把 lineWidth 设为 1px 到 10px、globalAlpha 设为 0 到 1,让尾部逐渐变细、变淡。使用圆形 lineCap,颜色取自 CSS 自定义属性中的强调色。只在有点存在时运行循环。用 devicePixelRatio 和 ResizeObserver 设置画布尺寸,并忽略触摸指针,避免阻挡手机滚动。在 prefers-reduced-motion 下不绘制拖尾。说明文字为“用指针画画看”。

源代码

Demo.tsx
'use client'

import { useEffect, useRef } from 'react'
import s from './demo.module.css'

const LIFE = 450

export default function CursorTrail({ labels }: { labels: Record<string, string> }) {
  const ref = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const canvas = ref.current!
    const ctx = canvas.getContext('2d')!
    const color = getComputedStyle(canvas).getPropertyValue('--trail').trim()
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
    let points: { x: number; y: number; t: number }[] = []
    let raf = 0
    let w = 0
    let h = 0

    const resize = () => {
      const dpr = window.devicePixelRatio || 1
      w = canvas.clientWidth
      h = canvas.clientHeight
      canvas.width = w * dpr
      canvas.height = h * dpr
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    }

    const draw = () => {
      const now = performance.now()
      points = points.filter((p) => now - p.t < LIFE)
      ctx.clearRect(0, 0, w, h)
      ctx.strokeStyle = color
      ctx.lineCap = 'round'
      for (let i = 1; i < points.length - 1; i++) {
        const life = 1 - (now - points[i].t) / LIFE
        const a = points[i - 1]
        const b = points[i]
        const c = points[i + 1]
        ctx.globalAlpha = life
        ctx.lineWidth = 1 + life * 9
        ctx.beginPath()
        ctx.moveTo((a.x + b.x) / 2, (a.y + b.y) / 2)
        ctx.quadraticCurveTo(b.x, b.y, (b.x + c.x) / 2, (b.y + c.y) / 2)
        ctx.stroke()
      }
      ctx.globalAlpha = 1
      raf = points.length ? requestAnimationFrame(draw) : 0
    }

    const onMove = (e: PointerEvent) => {
      if (reduced || e.pointerType === 'touch') return
      const r = canvas.getBoundingClientRect()
      points.push({ x: e.clientX - r.left, y: e.clientY - r.top, t: performance.now() })
      if (!raf) raf = requestAnimationFrame(draw)
    }

    const ro = new ResizeObserver(resize)
    ro.observe(canvas)
    canvas.addEventListener('pointermove', onMove)
    return () => {
      cancelAnimationFrame(raf)
      ro.disconnect()
      canvas.removeEventListener('pointermove', onMove)
    }
  }, [])

  return (
    <div className={s.wrap}>
      <canvas ref={ref} className={s.canvas} aria-hidden="true" />
      <span className={s.caption}>{labels.caption}</span>
    </div>
  )
}
demo.module.css
.wrap {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}

.canvas {
  --trail: var(--stage-accent);
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  cursor: crosshair;
}

.caption {
  position: relative;
  font: 500 var(--text-xs) / 1 var(--font-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--stage-muted);
  pointer-events: none;
}

投稿创意

看到想收录的动效或主题?简单描述一下,会打开一个预先填好的 GitHub Issue,确认后即可提交。

你想投稿的是?

需要 GitHub 账号,投稿内容会公开。