May 30, 2026 · The Studio
A custom cursor is one of those touches that makes a site feel hand-crafted — when it's done with restraint. Ours keeps the native cursor visible and adds a small accent dot that trails it, leans into its own movement, and morphs into a label, a thumbnail, or an icon depending on what you're hovering. When you stop moving, it parks itself and costs nothing.
This is the whole thing: the follow loop, the skew, the declarative hover API, and — the part most custom cursors get wrong — never getting stuck.

The shape
A fixed, pointer-events: none element with an inner body and a content slot:
<div className="cb-cursor"> {/* GSAP moves this: x / y / rotation */}
<div className="cb-cursor-inner"> {/* GSAP skews this: skewX / rotation */}
<span className="cb-cursor-content">{content}</span>
</div>
</div>.cb-cursor {
position: fixed;
top: 0; left: 0;
pointer-events: none; /* never blocks a click */
z-index: 9999;
}One rule we never break: GSAP owns the transform of .cb-cursor and
.cb-cursor-inner. If CSS also writes transform on them, the two fight and
the cursor jitters. Size changes animate width/height/margin; the content
slot scales independently. Keep transforms for the JS, geometry for the CSS, and
the two never collide.
The follow loop: lerp, don't track
If the dot sat exactly on the pointer it would feel glued and lifeless. Instead it lerps (linearly interpolates) toward the pointer a fraction each frame, so it trails with a natural, weighted lag:
const LAG = 0.18; // 0–1, higher = tighter follow
let px = 0, py = 0; // pointer
let cx = 0, cy = 0; // cursor (eased)
function frame() {
cx += (px - cx) * LAG; // ease toward the pointer
cy += (py - cy) * LAG;
setCursor(cx, cy); // gsap.quickSetter, bound once
// …skew (below)…
loop();
}cx += (px - cx) * LAG is the entire physics. Each frame closes 18% of the gap
to the pointer — fast enough to feel responsive, slow enough to feel like it has
weight.
For the per-frame write we use gsap.quickSetter, bound once outside the
loop, and mutate plain numbers rather than allocating objects every frame. The
hot path stays allocation-free, which is what keeps a 120Hz display smooth:
const setX = gsap.quickSetter(el, "x", "px");
const setY = gsap.quickSetter(el, "y", "px");
const setCursor = (x, y) => { setX(x); setY(y); };Skew: lean into the travel
What turns a follower into something that feels alive is a directional skew. The faster it moves, the more the body leans toward where it's going — then eases back to flat as it settles. We derive velocity from the gap it just closed and clamp it:
const SKEW_K = 0.9; // velocity → skew degrees
const MAX_SKEW = 14; // clamp, degrees
const vx = px - cx, vy = py - cy; // this frame's velocity
const speed = Math.hypot(vx, vy);
const skew = Math.max(-MAX_SKEW, Math.min(MAX_SKEW, speed * SKEW_K));
const angle = Math.atan2(vy, vx) * (180 / Math.PI);
skewSetter(skew); // skewX on .cb-cursor-inner
rotateSetter(angle); // point the lean along travelThere's a second, subtler source of motion: scroll. When the page scrolls under a still pointer, the dot should lean as content rushes past, even though the pointer hasn't moved. So a wheel listener injects a decaying skew:
const SCROLL_K = 0.05; // wheel delta → skew
const SCROLL_DECAY = 0.82; // per-frame falloff toward flat
let scrollSkew = 0;
addEventListener("wheel", (e) => { scrollSkew += e.deltaY * SCROLL_K; });
// each frame:
scrollSkew *= SCROLL_DECAY; // eases back to 0 on its ownThese constants are the cursor's whole personality. Drop LAG for a heavier
trail, raise MAX_SKEW for a more theatrical lean — the feel is five numbers at
the top of the file.
Park when idle
A requestAnimationFrame loop running forever is wasted battery when nothing
moves. So the loop is on-demand: pointer-move and wheel events start it, and
it parks itself once the dot has caught up and both pointer- and
scroll-velocity hit zero:
let running = false;
function loop() {
const settled =
Math.hypot(px - cx, py - cy) < 0.1 && Math.abs(scrollSkew) < 0.1;
if (settled) { running = false; return; } // park — no more frames
requestAnimationFrame(loop);
}
function kick() { if (!running) { running = true; requestAnimationFrame(loop); } }
addEventListener("pointermove", (e) => { px = e.clientX; py = e.clientY; kick(); });
addEventListener("wheel", kick);An idle pointer costs zero per-frame work. The loop only exists while there's something to animate.
The hover API: declarative, event-delegated
A cursor is only useful if elements can change it. We expose three layers, from zero-wiring to full control. The common cases are just data attributes, picked up by a single delegated listener on the document — so dynamically rendered and page-transitioned content works with no per-element setup:
// a text label inside the cursor
<a href="/contact" data-cursor-text="Say hi">Contact</a>
// a state class only (ring / blend / scale…)
<button data-cursor="-blend">Toggle</button>
// an image or video preview (file extension picks <img> vs <video>)
<li data-cursor-media="/projects/aurora.jpg">Aurora</li>// one listener for the whole page
document.addEventListener("pointerover", (e) => {
const el = e.target.closest("[data-cursor], [data-cursor-text], [data-cursor-media]");
if (!el) return;
if (el.dataset.cursor) addStateClass(el.dataset.cursor);
if (el.dataset.cursorText) showText(el.dataset.cursorText);
if (el.dataset.cursorMedia) showMedia(el.dataset.cursorMedia);
});For a JSX cursor — an icon, a badge, a whole component — data attributes
can't help (they're strings only), so there's a <Cursor> wrapper:
import { Cursor } from "@/components/cursor/CursorProvider";
import { ArrowUpRight } from "lucide-react";
<Cursor content={<ArrowUpRight />} className="-ring">
<a href="/work">See our work</a>
</Cursor>;And for the rare case you can't wrap the target, an imperative useCursor() hook
whose set() returns a reset function you call on leave. Calls nest, so the
most recently set, still-active state wins when hover regions overlap.
The hard part: never get stuck
This is where most custom cursors fall down. A cursor set on hover has to reset on
leave — and onPointerLeave quietly misses two cases:
- The pointer leaves fast to empty space, between event samples.
- The element scrolls out from under a stationary pointer — no leave event fires at all, because the pointer never moved.
Miss either and the cursor stays stuck in its hovered state until something else
happens to change it. The fix is to give the cursor an owner — the element
it's currently reacting to — and let the provider auto-clear the instant the
pointer is no longer over that element:
type CursorState = {
content?: ReactNode;
className?: string;
owner?: Element | null; // the hovered element → provider auto-clears on leave
};The <Cursor> wrapper passes owner for you (which is why it "just works"); with
the hook you pass it yourself. On top of that, two safety nets cover the rest:
- Re-resolve hover on scroll. When the page scrolls beneath a still
pointer, a throttled
elementFromPointhit-test re-checks what's underneath — so a target scrolling into place activates, and one scrolling away clears, with no pointer move. - Reset on route change. A target that animates away during a page transition can't leave the cursor stranded.
Between owner, the scroll hit-test, and the route-change reset, there is no path
that leaves the dot stuck. That robustness — not the skew — is what separates a
cursor you ship from a cursor you rip out a week later.

Know when to switch it off
A cursor accent is meaningless on a touchscreen and unwelcome under reduced motion. Gate it in CSS so it never even initializes where it shouldn't:
@media (prefers-reduced-motion: reduce) { .cb-cursor { display: none; } }And gate the listeners behind pointer: fine so coarse/touch pointers never pay
for any of it.
The standard
- Lerp toward the pointer; never track it exactly.
- Skew with velocity, from both pointer and scroll, easing back to flat.
- Park the loop when idle so an unused cursor costs nothing.
- Declarative API — data attributes for the common cases, a wrapper for JSX, a hook for the edge.
- Owner-based auto-clear so it can't get stuck — the difference between a toy and a tool.
Small in code, big in feel. Get the leave-handling right and it disappears into the experience, which is exactly where a good cursor belongs.
