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:

  1. 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.
  2. One listener set. pointermove, pointerdown, pointerup and pointerover, all passive, attached to the document or to the scope element.
  3. 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.

dot-ring — the per-frame path
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.body and sized to the viewport, so nothing in the scope's own subtree can clip it. The provider applies a clip-path computed 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.