Cursor Trail
Ten chained dots that follow the cursor through interpolation: each dot closes 30% of the gap to its predecessor every frame, so the tail lags far behind the head — stretching on sharp turns and retracting at rest.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Cursors has 26 effects, including 1 free. Explore the category →
3 usage examples



How it works
At startup, the script creates ten div elements (class cursor-trail), appends them to document.body, and stores them in trailDots with coordinates initialized to (0, 0). Each dot receives a fixed opacity at creation — 1 − i × 0.1, from 1.0 to 0.1 — and a fixed scale — scale(1 − i × 0.08), from 1.0 to 0.28. These values never change. All dots stay at display: none until mouseenter on the zone; mouseleave hides them. The cursor position is captured into trailX / trailY on every mousemove.
All the kinematics happen inside animateTrail(), driven by requestAnimationFrame at startup and running continuously. Each frame, the ten dots are iterated in order. Dot 0 pursues the current cursor position; each subsequent dot pursues the one before it. The formula is the same for all: dot.x += (prev.x − dot.x) × 0.3. Covering 30% of the remaining gap each frame is an asymptotic interpolation that never overshoots. The lag accumulates link by link: the last dot has only (0.7)^9 ≈ 4% of the head's responsiveness.
Two implementation details have direct consequences. The dots are appended to body, not to the zone: their coordinates (clientX / clientY) are viewport positions — nothing clips them. And the rAF loop never stops: while display: none hides the dots, the ten elements keep interpolating toward the last known coordinates — when mouseenter shows them again, the trail resumes without a jolt. Each dot's position is offset by −5 px on both axes to center a 10 × 10 px dot.
Accessibility
- prefers-reduced-motion: absent from the code. The rAF loop runs unconditionally and
cursor-activehides the native cursor regardless. Gate the initialanimateTrail()call and the event listeners behind an invertedwindow.matchMedia('(prefers-reduced-motion: reduce)')check — users who enabled that preference should not lose their cursor. - On touch screens, no
mousemovefires and the trail is inert. However, atouchstartcan trigger a syntheticmouseenteron some browsers, activatingcursor-activeand hiding the cursor with no visible trail. Gate the listeners behindmatchMedia('(hover: hover) and (pointer: fine)')to block that emulation. - Multiple instantiated zones means as many parallel rAF loops. To stop the loop outside hover, store the rAF id, call
cancelAnimationFrame()onmouseleave, and restart onmouseenter. For several simultaneous zones, a single shared global loop managing all active instances is the cleaner approach. - The ten dots are empty
divs inbody, carrying no role or text. Addaria-hidden="true"to each dot during the creation loop to explicitly exclude them from the accessibility tree and prevent a screen reader from enumerating them during navigation.
Browser compatibility
The limit is on the JavaScript side: arrow functions and a template literal (for the scale() attribute). IE 11 halts on a syntax error before the first dot is created. The CSS that positions the dots (position: fixed, pointer-events: none) and hides the native cursor (cursor: none) has been universal since 2012.
Without JavaScript, no dots are created and no trail appears — a sound fallback: the effect carries nothing functional. On mobile, without <code>mousemove</code>, dots stay at <code>display: none</code> and the system cursor shows normally. If you need ES5 coverage, transpile the script: the logic has no other dependency.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| Number of dots (10 in the initialization loop) | 10 | Direct control of trail length. Each extra dot is one more div in the DOM and one more lerp per frame — stay below 20. 4–5 = short, tight tail; 15–18 = an undulation that persists long after a sharp turn. |
| Interpolation factor (0.3 in animateTrail) | 0.3 | Fraction of the remaining gap each dot closes per frame. 0.1 = a very floaty trail, the tail lingers; 0.5 = a taut trail, the tail catches the head quickly. Adjust this before touching the dot count. |
| Opacity gradient (coefficient 0.1 per rank) | 1 − i × 0.1 | Set at creation, never updated. 0.05 = very gradual fade; 0.15 = abrupt fade. With 10 dots, do not exceed 0.1: beyond that, the later ranks are invisible from the moment of creation. |
| Size gradient (coefficient 0.08 per rank) | scale(1 − i × 0.08) | Also frozen at creation. 0.05 gives a tail at 55%; 0.12 gives the last dot at 8% — nearly invisible. Combine with the opacity gradient to tune size and visibility independently. |
| Centering offset (−5 in the JS) | −5 px on both axes | Position written as dot.x − 5 and dot.y − 5 to center a 10 × 10 px dot. CSS size at 8 px → offset −4; at 16 px → offset −8. A mismatched offset shifts the entire trail away from the true pointer. |
| Parent element for dots (document.body) | document.body | Dots use clientX / clientY — viewport coordinates, correct only when the parent has no positioning context of its own. To clip the trail to the zone, move appendChild to the zone and switch to e.offsetX / e.offsetY with overflow: hidden. |
FAQ
cancelAnimationFrame() on mouseleave, and restart on mouseenter.clientX / clientY — viewport positions. Appended to body, they place correctly without any offset calculation. Inside a parent with its own positioning context, the origin shifts and all dots drift. To confine them to the zone, switch to e.offsetX / e.offsetY with position: absolute inside a position: relative parent and overflow: hidden.cursor-trail CSS class: change border-radius to go from circle to square; background for a flat color or radial gradient; box-shadow for a glow. If you change the CSS size, adjust the −5 centering offset accordingly. To give a different color to each rank, access trailDots[i].el during the initialization loop and set the color directly on the element.