Cursor Trail Effect
Five fixed 8 px dots, opacity 0.7 to 0.3, chasing each other in a chain: each one closes on the previous at 30%, 25%, 20%, 15% then 10% of the gap per frame. On a 456 px jump the trail stretches to 406 px and the last dot takes 79 frames to catch up. The native cursor is hidden inside the zone.
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
The HTML provides five empty divs #trailV2-1 to #trailV2-5, all .cursor-trail-v2 — position: fixed, 8 × 8 px, #6366f1, border-radius: 50%, pointer-events: none, z-index: 9999, display: none — grouped in .cursor-trail-v2-set so that :nth-child(1) to (5) give them a decreasing opacity: 0.7, 0.6, 0.5, 0.4, 0.3 (measured). No CSS transition, on purpose: the position is rewritten every frame by the JS.
The #zone-trail-v2 area listens to mouseenter (cursor-active class — whose cursor: none !important rule is shipped this time, measured cursor: none — and display: block on all five), mouseleave (the reverse) and mousemove, which only stores trailV2X/Y. All the motion lives in a requestAnimationFrame loop: for dot i, the target is the pointer if i === 0, otherwise dot i − 1's position; speed = 0.3 − i × 0.05; pos.x += (target.x − pos.x) × speed; then left = pos.x − 4.
It is a chain of exponential approaches: the first dot closes 30% of its lag per frame, the last 10%, and each one only aims at its predecessor. Measured at 120 Hz after a 456 px jump: gap to the pointer at frame 5 = 54, 177, 315, 407 and 445 px for dots 1 to 5; maximum trail stretch 406 px at frame 7; back under 1 px at frames 17, 26, 37, 52 and 79 (≈ 0.65 s for the tail). The trail length therefore depends on hand speed, not on a fixed duration.
Positions are initialized to {x: 0, y: 0}: on first entry the five dots fly in from the top-left corner of the window (measured at frame 0 for a pointer at 152, 150: dot 1 at 46, 45 … dot 5 at 0, 0). The loop never stops — 120 rAF calls/s measured with no hover and the dots hidden — ten style writes per frame for nothing. And the smoothing is counted in frames: on a 60 Hz screen, catching up takes twice as long in milliseconds as at 120 Hz.
Accessibility
- Dot contrast (WCAG 1.4.11, 3:1 threshold):
#6366f1at 0.7 opacity on#1a1a24= 2.5:1, at 0.3 = 1.4:1 — below the threshold for all of them. The trail is decorative, but withcursor: nonethe first dot is the only pointer landmark: set it toopacity: 1(3.9:1) or pick#a5b4fc(4.9:1 at 0.7). cursor: nonehides the arrow while the lead dot follows with a lag (30% per frame): the exact pointer position is never shown. On a zone with click targets, keep the arrow (remove the.cursor-activerule) and let the trail ride along.- Touch: measured with touch emulation, a tap inside the zone shows the five dots frozen and leaves
cursor-activeset even after a tap outside the zone (nomouseleave). Gate the initialization onmatchMedia('(hover: hover) and (pointer: fine)').matches. - prefers-reduced-motion not handled: a trail gliding behind the hand is an interaction-triggered animation (WCAG 2.3.3). Under
@media (prefers-reduced-motion: reduce), hide dots 2 to 5 (.cursor-trail-v2:not(:first-child) { display: none !important }) or set every speed to 1. - Screen readers and keyboard: five empty
divs,pointer-events: none, nothing is announced — addaria-hidden="true"on the.cursor-trail-v2-setgroup. No keyboard trigger.z-index: 9999: the dots float above modals.
Browser compatibility
Requires ES2015 (const/let, arrow functions, Array.prototype.some/map), classList, requestAnimationFrame and the zone's CSS variables. Zero dependencies.
Without JavaScript, the five dots stay display: none and the system arrow is kept. The JS bails out cleanly if any of the five ids is missing (if (trailV2Elements.some(el => !el)) return): deleting one dot in the HTML disables the whole effect — shorten the array too.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="cursor-zone" id="zone-trail-v2">
Hover this area
<span class="info-text">Smooth 5-dot trail</span>
</div>
<div class="cursor-trail-v2-set">
<div class="cursor-trail-v2" id="trailV2-1"></div>
<div class="cursor-trail-v2" id="trailV2-2"></div>
<!-- … 5 in total, one per trail dot … -->
</div>
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 |
|---|---|---|
Speeds (JS — speed = 0.3 - (i * 0.05)) |
0.3 → 0.1 | Share of the gap closed per frame for each dot. Lowering the slope (0.04) lengthens the trail; raising the first term (0.5) glues the lead dot to the pointer. |
Number of dots (HTML — divs; JS — trailV2Elements array; CSS — :nth-child) |
5 | All three must agree: the JS bails out if an id is missing. With 8 dots, extend the formula (0.3 − i × 0.03) so the last speed stays positive. |
Size (CSS — .cursor-trail-v2 8px) + offset (JS — pos.x - 4) |
8 px / −4 | Coupled: the offset must stay half the size to center the dots. Decreasing sizes via :nth-child give a tapered trail (keep the largest one's offset). |
| Color (CSS — .cursor-trail-v2 background) | #6366f1 | One color for all five dots. Through :nth-child, you can shift the hue (indigo → pink) on top of the opacity. |
| Opacities (CSS — .cursor-trail-v2:nth-child(n)) | 0.7 · 0.6 · 0.5 · 0.4 · 0.3 | Fade along the trail. Dot 1 at 1 gives a crisp landmark (3.9:1); wider steps (0.9 → 0.1) make the tail nearly invisible. |
Initial position (JS — map(() => ({x: 0, y: 0}))) |
0, 0 | Cause of the start from the top-left corner. In mouseenter, reset trailV2X/Y and each pos to e.clientX/Y. |
Stopping the loop (JS — animateTrailV2) |
permanent | Store the requestAnimationFrame id, cancel it in mouseleave with cancelAnimationFrame, restart in mouseenter: zero work outside hover. |
FAQ
mouseenter, the dots show up where they are, in the top-left corner, then chase the pointer in cascade — measured: dot 1 at (46, 45) and dot 5 still at (0, 0) at frame 0 for a pointer at (152, 150). Add to mouseenter: trailV2X = e.clientX; trailV2Y = e.clientY; trailV2Positions.forEach(p => { p.x = e.clientX; p.y = e.clientY; }).transition on left/top — restarted 60 times a second, it would make the tail crawl (that is the effect measured on Dot Follower, fx-0197, where it doubles the catch-up time). Write transform: translate3d() instead of left/top to stay on the compositor.</body>, inside their .cursor-trail-v2-set container (required by the :nth-child rules). position: fixed with left = clientX assumes the window as the reference: any ancestor with transform, filter, perspective, will-change: transform or contain: paint becomes the containing block and shifts the trail by its own position. The shipped code places the divs inside the demo box: move them to the root when integrating.