延迟跟随光圈
小圆点紧贴指针,一个较大的光圈在后面跟随,悬停到按钮上时会包住按钮。
用 GSAP 在 React 中制作由两部分组成的自定义光标。渲染一个 6px 的圆点和一个 36px 的光圈,都使用绝对定位和 pointer-events: none。为两者的 x、y 分别创建 gsap.quickTo:圆点 duration 0.08,光圈 duration 0.45,ease 为 "power3.out",让光圈落后于指针。pointermove 时用区域内的指针位置调用这四个函数。悬停到带 data-cursor-target 的元素上时,在 0.3 秒内把光圈补间到该元素的中心并匹配其尺寸(宽、高、圆角都等于元素加 8px),同时淡出圆点;离开目标时恢复。触摸时隐藏两者。为键盘用户保留原生焦点环。在 prefers-reduced-motion 下两者都使用 duration 0,不产生拖尾。目标为三个导航按钮:作品、关于、联系。
源代码
'use client'
import gsap from 'gsap'
import { useEffect, useRef } from 'react'
import s from './demo.module.css'
type QuickTo = ReturnType<typeof gsap.quickTo>
export default function CursorFollower({ labels }: { labels: Record<string, string> }) {
const area = useRef<HTMLDivElement>(null)
const dot = useRef<HTMLSpanElement>(null)
const ring = useRef<HTMLSpanElement>(null)
const setters = useRef<QuickTo[]>([])
const locked = useRef(false)
useEffect(() => {
const nodes = [dot.current, ring.current]
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const opts = (duration: number) => ({ duration: reduced ? 0 : duration, ease: 'power3.out' })
gsap.set([dot.current, ring.current], { xPercent: -50, yPercent: -50 })
setters.current = [
gsap.quickTo(dot.current, 'x', opts(0.08)),
gsap.quickTo(dot.current, 'y', opts(0.08)),
gsap.quickTo(ring.current, 'x', opts(0.45)),
gsap.quickTo(ring.current, 'y', opts(0.45)),
]
return () => gsap.killTweensOf(nodes)
}, [])
const onMove = (e: React.PointerEvent) => {
if (e.pointerType === 'touch' || !area.current) return
const r = area.current.getBoundingClientRect()
const x = e.clientX - r.left
const y = e.clientY - r.top
const [dx, dy, rx, ry] = setters.current
gsap.to([dot.current, ring.current], { opacity: 1, duration: 0.2 })
dx?.(x)
dy?.(y)
if (!locked.current) {
rx?.(x)
ry?.(y)
}
}
const enterTarget = (e: React.PointerEvent<HTMLButtonElement>) => {
if (e.pointerType === 'touch' || !area.current) return
locked.current = true
const a = area.current.getBoundingClientRect()
const t = e.currentTarget.getBoundingClientRect()
gsap.to(ring.current, {
x: t.left - a.left + t.width / 2,
y: t.top - a.top + t.height / 2,
width: t.width + 8,
height: t.height + 8,
borderRadius: 12,
duration: 0.3,
ease: 'power3.out',
})
gsap.to(dot.current, { opacity: 0, duration: 0.15 })
}
const leaveTarget = () => {
locked.current = false
gsap.to(ring.current, { width: 36, height: 36, borderRadius: 18, duration: 0.3, ease: 'power3.out' })
gsap.to(dot.current, { opacity: 1, duration: 0.15 })
}
return (
<div
ref={area}
className={s.area}
onPointerMove={onMove}
onPointerLeave={() => gsap.to([dot.current, ring.current], { opacity: 0, duration: 0.2 })}
>
<nav className={s.nav}>
{[labels.a, labels.b, labels.c].map((t) => (
<button key={t} type="button" className={s.item} data-cursor-target onPointerEnter={enterTarget} onPointerLeave={leaveTarget}>
{t}
</button>
))}
</nav>
<span ref={ring} className={s.ring} aria-hidden="true" />
<span ref={dot} className={s.dot} aria-hidden="true" />
</div>
)
}
.area {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
@media (pointer: fine) {
.area, .area * { cursor: none; }
}
.nav { display: flex; gap: 6px; }
.item {
height: 40px;
padding: 0 16px;
border: 0;
border-radius: 10px;
background: none;
color: var(--stage-ink);
font: 600 var(--text-base) / 1 var(--font-body);
white-space: nowrap;
}
.item:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.ring, .dot {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
pointer-events: none;
opacity: 0;
}
.ring {
width: 36px;
height: 36px;
border: 1.5px solid var(--stage-accent);
}
.dot {
width: 6px;
height: 6px;
background: var(--stage-ink);
}