Lagging Follower Ring
A small dot sticks to the pointer while a larger ring trails behind it, then wraps around buttons you hover.
Build a two-part custom cursor in React with GSAP. Render a 6px dot and a 36px ring, both absolutely positioned with pointer-events: none. Create gsap.quickTo setters for x and y on each: the dot with duration 0.08 and the ring with duration 0.45, ease "power3.out", so the ring lags behind. On pointermove call all four setters with the pointer position inside the area. When hovering an element with data-cursor-target, tween the ring to that element’s centre and size (width, height and border-radius matching the element plus 8px) over 0.3s, and fade the dot out; restore on pointerleave of the target. Hide both on touch. Keep native focus rings for keyboard users. Under prefers-reduced-motion, use duration 0 for both so nothing trails. Targets are three nav buttons: Work, About, Contact.
Source
'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);
}