Lagging Follower Ring

A small dot sticks to the pointer while a larger ring trails behind it, then wraps around buttons you hover.

  • GSAP
  • Cursor
  • Pointer
  • Added

AI prompt

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

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);
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.