Engine
How the singleton engine works, what scoped mode is for, and the five rules any new variant has to follow.
The engine is plain TypeScript with GSAP as its only dependency. It has no React imports at all — that is enforced by a lint rule rather than a convention, because the folder is intended to become a standalone package and a single React import would turn that extraction into a rewrite.
What it owns
Three things, each exactly once per engine:
- Pointer state. A mutable object holding
x,y, previous position, velocity, speed, whether the button is down, whether the pointer is visible, and the current hover target. - One listener set.
pointermove,pointerdown,pointerupandpointerover, all passive, attached to the document or to the scope element. - One ticker callback. Ref-counted on
gsap.ticker, added when the first layer or tick subscriber appears and removed when the last one goes.
Layers register with it and get back three setters. Variants subscribe to onTick and read
the state. Nothing else touches the pointer.
Layers and quickTo
engine.registerLayer(el, options) positions the element, applies the base styles every
layer needs — position: fixed, pointer-events: none, will-change: transform — and
builds the setters.
The setters are gsap.quickTo, not gsap.to. This is the entire performance argument for
using GSAP here: gsap.to() inside a move handler allocates a tween object per event, which
on a fast pointer is hundreds of allocations a second. quickTo compiles the setter once and
then only writes numbers.
const offTick = engine.onTick((state) => {
if (!state.isVisible) return;
if (!revealed) {
revealed = true;
// Jump to position before fading in, so the reveal is not a flight.
gsap.set([dotEl, ringEl], { x: state.x, y: state.y });
gsap.to([dotEl, ringEl], { opacity: 1, duration: 0.2 });
}
dot.setX(state.x);
dot.setY(state.y);
ring.setX(state.x);
ring.setY(state.y);
});That is the whole hot path for a variant: read two numbers off a shared object, hand them to two precompiled setters. No allocation, no React, no layout.
Hover targets
Targets are resolved by event delegation: one pointerover listener runs
event.target.closest('[data-cursor]'). There are no per-element listeners, which is what
makes the system work with content mounted after the engine started — a modal, a route
transition, a list that just fetched.
The target's bounding rect is read once on enter and cached. It is invalidated on scroll,
on resize, and by a ResizeObserver in border-box mode. Content-box is the default and it
misses padding-only changes, which is a real bug and not a theoretical one — the magnetic ring
lands in the wrong place after a target's padding animates.
Scoped mode
By default an engine owns the whole page. Pass a scope element and pointer tracking, the
native-cursor hide, and layer visibility are all confined to that element's bounds:
const arena = useRef<HTMLDivElement>(null);
<div ref={arena}>
<CursorProvider scope={arena}>
<DotRing />
</CursorProvider>
</div>;This exists because a page-global effect and a gallery of ten demos are in direct conflict. It is what makes every card on this site a live component rather than a video. Two details it handles that a naive version does not:
- Leaving the scope clears the target. The delegated listener lives on the scope element, so a pointer that exits while over a target would otherwise leave that target latched forever.
- Layers are clipped to the scope. A layer is portalled to
document.bodyand sized to the viewport, so nothing in the scope's own subtree can clip it. The provider applies aclip-pathcomputed from the scope's geometry instead.
Rules for a new variant
The library's constraints are not style preferences. Each of these has a specific failure mode behind it.
Never put pointer position in React state. A mousemove that triggers a re-render is the
most common way this pattern goes from 60fps to 20. Coordinates live in the engine; read them
inside a useGSAP callback.
Never attach your own listener or requestAnimationFrame. Use engine.onTick. Two
render loops means two sources of truth about time, and they will disagree.
Animate transforms and opacity only. x, y, scale, rotation, opacity. Writing
width, height, top, left or border-radius per frame triggers layout. The magnetic
variant morphs with scaleX/scaleY on an SVG rect for exactly this reason, and the
measurements behind that choice are in the repo notes.
Bail out on coarse pointers. If matchMedia('(pointer: fine)') does not match, the
engine returns an inert instance — layers register and simply never receive a frame, so
variants need no null checks and touch devices pay nothing.
Clean up everything. Every onTick subscription, every layer, every portal. The engine
removes its ticker callback when the last layer unregisters; a leaked subscription keeps the
ticker alive for the life of the page. There is a mount/unmount stress test in the repo that
runs a hundred cycles and asserts the ticker ends empty.