Cursor Custom logoCursorCustom

Custom Cursor Performance: Keeping Effects Inside the 16 ms Frame Budget

By Cursor Custom TeamPublished October 26, 20267 min read
Frame time chart comparing a full-screen particle canvas with a scoped, lighter cursor canvas

A cursor effect is the most performance-sensitive animation on a page, because it is tied directly to the visitor’s hand. Any delay between moving the mouse and seeing the response feels like the site is broken. Here is how to keep custom cursor effects smooth on real devices.

Understand the frame budget

A display refreshing at 60 Hz shows a new frame every 16.7 milliseconds. In that window the browser must run your JavaScript, recalculate styles, lay out the page if needed, paint and composite. In practice, scripts should finish in well under 10 ms to leave room for the rest. On 120 Hz displays — common on recent laptops and phones — the budget halves to about 8.3 ms.

Miss the budget occasionally and the browser drops a frame, which shows as a stutter. Miss it constantly and the cursor effect lags visibly behind the real pointer. Because the pointer itself is drawn by the operating system at full speed, any lag in the effect is obvious by comparison.

Rule 1: Drive everything with requestAnimationFrame

Pointer events can fire far more often than the screen refreshes — gaming mice report at 1000 Hz. Drawing on every pointermove wastes work on frames nobody sees. Instead, store the latest coordinates in the event handler and draw once per frame:

let x = 0, y = 0, id = 0;
const onMove = e => { x = e.clientX; y = e.clientY; };
const tick = () => { draw(x, y); id = requestAnimationFrame(tick); };
window.addEventListener('pointermove', onMove, { passive: true });
id = requestAnimationFrame(tick);
// cleanup
cancelAnimationFrame(id);
window.removeEventListener('pointermove', onMove);

Every effect in the Cursor Custom gallery follows this pattern. When you adapt one, keep the cleanup. A loop left running after a component unmounts keeps burning CPU, and if the component mounts again you end up with two loops drawing two cursors.

Rule 2: Size the canvas to the job

A full-screen canvas on a 4K display at device pixel ratio 2 is over 33 million pixels per clear. Most cursor effects only need to cover the area where they are visible. The gallery scopes each canvas to its preview container for this reason, which saves both GPU memory and fill cost.

  • Use a container-sized canvas whenever the effect only appears in one section, such as a hero.
  • Cap device pixel ratio at 2: Math.min(devicePixelRatio, 2). Higher ratios add cost with little visible benefit for soft particles.
  • Resize the canvas on window resize, not on every frame.
  • Clear only the region you drew on when possible, instead of the full canvas.

Rule 3: Limit particles and pool them

Particle effects scale with count. Every particle costs a position update and a draw call each frame.

Device classComfortable particle countNotes
Recent desktop with dedicated GPU400–800Still keep glow and shadow modest
Typical laptop, integrated graphics150–300The sensible default for public sites
Older or low-power laptops60–120Detect via frame timing and scale down

Two habits make a large difference. First, enforce a hard cap: when the array is full, recycle the oldest particle instead of adding a new one. Second, avoid creating new objects every frame. Reusing a fixed pool of particle objects reduces garbage collection pauses, which otherwise appear as random hitches every few seconds.

Rule 4: Choose cheap drawing operations

Canvas operations do not cost the same.

  • shadowBlur is expensive. It is the most common cause of slow glow effects, especially in Safari. Fake glow with a pre-rendered radial gradient sprite drawn with drawImage, or apply blur only to a few large elements.
  • Batch by colour. Changing fillStyle for every particle adds overhead. Group particles of the same colour and fill them in one path.
  • Avoid text drawing in hot loops. Effects like Text Orbiter can pre-render characters to small offscreen canvases.
  • Prefer globalAlpha over building rgba strings. String creation for every particle adds garbage.

Rule 5: Let CSS and the compositor do the work

Not every cursor needs a canvas. A follower ring is a single element whose position changes every frame. If you move it with transform: translate3d(), the browser can composite it on the GPU without re-laying out the page. Moving it with left and top forces layout work every frame.

  • Animate only transform and opacity for DOM-based cursors.
  • Use will-change: transform on the moving element, and only on that element. The gallery applies it to transition-heavy elements; applying it everywhere wastes GPU memory.
  • Never read layout properties such as getBoundingClientRect() inside the frame loop after writing styles. That forces synchronous layout. Read first, then write.

Effects like Magnetic and Gravity Pull need element positions. Measure those positions on resize and scroll, cache them, and use the cache inside the loop.

Rule 6: Stop when nobody is watching

  • Off-screen: use an IntersectionObserver to pause the loop when the effect’s section scrolls out of view.
  • Idle pointer: when particles have faded and the mouse is still, stop scheduling frames. Restart on the next pointermove. Our own homepage hero trail works this way, so it uses no CPU when you are not moving the mouse.
  • Background tabs: browsers throttle requestAnimationFrame in hidden tabs, but listening for visibilitychange lets you also pause timers and audio.

Rule 7: Respect device and user signals

Performance is also about not running effects where they do not belong:

const ok = matchMedia('(pointer: fine)').matches
  && !matchMedia('(prefers-reduced-motion: reduce)').matches;

Touch devices have no hovering pointer, so pointer-following effects add work with little benefit. Visitors who request reduced motion should not get trails at all. Both checks cost nothing and remove the heaviest work from the devices most likely to struggle.

How to measure, step by step

  1. Open Chrome DevTools, Performance panel.
  2. In the capture settings, choose CPU: 4× slowdown to simulate a modest laptop.
  3. Click record, move the mouse continuously through the effect for five seconds, stop.
  4. Check the Frames track for red or yellow frames, and the summary for scripting versus rendering time.
  5. Enable Paint flashing from the Rendering panel to confirm only the cursor area repaints.
  6. Repeat in Safari’s Web Inspector timeline if you serve many Mac or iPhone visitors.

Set a target and write it down: for example, “average scripting under 4 ms per frame at 4× throttling.” A number turns “it feels fine on my machine” into a standard the whole team can check.

Adaptive quality

For heavier effects, measure real frame times in the first second and adjust automatically:

let frames = 0, start = performance.now();
function probe(t) {
  frames++;
  if (t - start < 1000) return requestAnimationFrame(probe);
  if (frames < 50) config.count = Math.round(config.count * 0.5);
}
requestAnimationFrame(probe);

This simple probe halves the particle count if the device cannot sustain about 50 frames per second. Visitors on fast machines get the full effect; others get a lighter version that still feels smooth.

Quick reference by effect type

Effect familyMain costFirst optimisation
Particle trails (Particle, Fire, Bubble)Particle count, glowCap count, replace shadowBlur with sprites
Line graphs (Constellation, DNA Helix)Distance checks between pointsLimit node count, use a spatial grid
Rings and followers (Elastic, Neon)Layout if positioned wronglyMove with transform only
DOM fields (Magnetic, Gravity)Reading element positionsCache rects on resize and scroll
Full-page overlays (Spotlight, Torch)Large gradient repaintsUpdate a CSS custom property, not a canvas
DOM clones (Fluid Glass)Cloned element countClone the smallest container possible

A pre-launch performance checklist

  • The effect mounts only on the pages and sections where it is wanted.
  • It starts only when (pointer: fine) matches and reduced motion is off.
  • All drawing runs inside one requestAnimationFrame loop.
  • The loop stops when the effect is idle, off-screen or unmounted.
  • Canvas size matches its container, with device pixel ratio capped at 2.
  • Particle count has a hard cap and objects are reused.
  • No shadowBlur on per-particle draws.
  • DOM-based cursors move with transform only.
  • Average frame time stays within budget at 4× CPU throttling in Chrome.
  • The page has been checked in Safari and Firefox as well as Chrome.

Pin this list next to your cursor library’s README. Ten quick checks before launch prevent the slow, laggy pointer that makes visitors leave.

Memory matters too

Frame time is the headline metric, but memory leaks cause slow degradation that frame tests miss. Open Chrome’s Memory panel, take a heap snapshot, navigate in and out of a page with the effect ten times, then take another. If detached canvas elements or event listeners keep growing, cleanup is incomplete. The gallery’s double cleanup pattern — cancelling the animation frame and removing every window listener on unmount — exists precisely to keep this graph flat. Keep it intact in your adapted code, and test it whenever you change how an effect mounts.

The payoff

A custom cursor that keeps up with the hand feels like part of the operating system. One that lags feels like a gimmick, no matter how pretty it is. Treat the 16.7 ms frame as a fixed budget, spend it carefully, and stop spending it when nobody is looking. The 24 effects in the Cursor Custom gallery give you a strong starting point; these rules keep them fast once they leave the gallery and meet your visitors’ real devices.