延迟跟随光圈

小圆点紧贴指针,一个较大的光圈在后面跟随,悬停到按钮上时会包住按钮。

  • GSAP
  • 光标
  • 跟随指针
  • 收录于

AI 提示词

用 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,不产生拖尾。目标为三个导航按钮:作品、关于、联系。

源代码

Demo.tsx
'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>
  )
}
demo.module.css
.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);
}

投稿创意

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

你想投稿的是?

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