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