Dot and ring cursor
A small dot sits exactly on the pointer while a larger ring trails behind it and swells over links.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const dot = useRef<HTMLDivElement>(null);
const ring = useRef<HTMLDivElement>(null);
const reduced = usePrefersReducedMotion();
useEffect(() => {
// Elements are read once here: a frame can still run after unmount, when refs are already null.
const [el, ringEl, dotEl] = [root.current, ring.current, dot.current];
if (!el || !ringEl || !dotEl || reduced) return;
const target = { x: 0, y: 0 };
const pos = { x: 0, y: 0 };
let raf = 0;
const frame = () => {
pos.x += (target.x - pos.x) * 0.18;
pos.y += (target.y - pos.y) * 0.18;
ringEl.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
raf = Math.hypot(target.x - pos.x, target.y - pos.y) > 0.1 ? requestAnimationFrame(frame) : 0;
};
const move = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
const rect = el.getBoundingClientRect();
target.x = event.clientX - rect.left;
target.y = event.clientY - rect.top;
dotEl.style.transform = `translate(${target.x}px, ${target.y}px)`;
const over = event.target instanceof Element && event.target.closest("[data-hover]");
el.classList.toggle(styles.hovering, Boolean(over));
el.classList.add(styles.active);
if (!raf) raf = requestAnimationFrame(frame);
};
const leave = () => el.classList.remove(styles.active);
el.addEventListener("pointermove", move);
el.addEventListener("pointerleave", leave);
return () => {
cancelAnimationFrame(raf);
el.removeEventListener("pointermove", move);
el.removeEventListener("pointerleave", leave);
};
}, [reduced]);
return (
<div ref={root} className={`${styles.root} ${reduced ? "" : styles.custom}`}>
<div className={styles.items}>
{["Villa Azul", "Casa Mar", "Olive House"].map((name) => (
<a key={name} href="#" data-hover className={styles.item} onClick={(e) => e.preventDefault()}>
{name}
</a>
))}
</div>
<div ref={ring} className={styles.ring} aria-hidden="true"><span /></div>
<div ref={dot} className={styles.dot} aria-hidden="true"><span /></div>
</div>
);
}
demo.module.css
.root { position: relative; width: 100%; height: 100%; display: grid; place-items: center; overflow: hidden; }
.custom, .custom * { cursor: none; }
.items { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; padding: 16px; }
.item { padding: 14px 20px; border-radius: 12px; background: var(--white); border: 1px solid var(--line); font-weight: 600; text-decoration: none; transition: border-color .18s; }
.item:hover { border-color: var(--accent-text); }
.dot, .ring { position: absolute; left: 0; top: 0; pointer-events: none; opacity: 0; transition: opacity .2s; }
.active .dot, .active .ring { opacity: 1; }
.dot span, .ring span { position: absolute; border-radius: 50%; translate: -50% -50%; }
.dot span { width: 8px; height: 8px; background: var(--ink); transition: scale .2s var(--out); }
.ring span { width: 36px; height: 36px; border: 1.5px solid var(--ink); transition: width .3s var(--out), height .3s var(--out), background-color .3s, border-color .3s; }
.hovering .ring span { width: 64px; height: 64px; background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent-text); }
.hovering .dot span { scale: 0; }
@media (prefers-reduced-motion: reduce) { .dot, .ring { display: none; } }
Use it for
- Portfolio and studio sites where the cursor is part of the brand
- Galleries, to signal which items are interactive
Avoid
- Product UI and forms, where the native cursor is faster to read
- Text-heavy pages, since the ring hides the caret position
How it works
The dot is moved to the pointer on every pointermove. The ring position is smoothed in a requestAnimationFrame loop with linear interpolation (ring += (target - ring) * 0.18), so it lags and settles. When the pointer is over an element with data-hover, a class grows the ring and fades the dot. Both use transform only.
Accessibility
The native cursor is only hidden inside the demo area and only for mouse and pen. Touch input and reduced motion keep the native cursor and skip the loop. The custom elements are aria-hidden.