Trail Particles
Every mousemove drops two purple dots (3 to 9 px) under a gravity of 0.05 px per frame: they drift, fall back, shrink and fade out in 0.55 to 1.1 s — a requestAnimationFrame loop that destroys and recreates every dot on every frame.
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 script walks every [data-trail-particles] and attaches a mousemove listener with no rate limiter: on each event, two particles are pushed into an array (for (f = 0; f < 2; f++)). Each one is born at the pointer coordinates relative to the zone (getBoundingClientRect) with a horizontal velocity of 3 × (Math.random() − .5), i.e. −1.5 to +1.5 px per frame, a vertical velocity of 3 × (Math.random() − .5) − 1, i.e. −2.5 to +0.5 px per frame (the −1 bias sends it upward first), a life of life = 1, a decay of decay = .015 + .015 × Math.random(), a diameter of 3 + 6 × Math.random() (3 to 9 px) and a color drawn from six purples and indigos (#a78bfa, #6366f1, #8b5cf6, #c084fc, #e879f9, #818cf8).
The l() loop runs on requestAnimationFrame as long as the array isn't empty (an o flag prevents starting it twice). On each frame it applies the physics: x += vx, y += vy, then vy += .05 (gravity), vx *= .99 (friction) and life −= decay. With a decay of 0.015 to 0.03 per frame, a particle lives 33 to 67 frames, i.e. 0.55 to 1.1 s at 60 frames per second. On a one-second sweep measured in Chromium, at most 18 particles coexisted.
Rendering starts from scratch on every frame: all .trail-particle elements are removed (querySelectorAll(...).forEach(e => e.remove())), then a fresh div is created per living particle — left/top in px, width/height = size × life (the dot shrinks as it dies), opacity = life, background in its color and a box-shadow: 0 0 2·size px glow in the same color. Measured: no node survives from one frame to the next. The companion CSS rule .trail-particle (position: absolute, border-radius: 50%, pointer-events: none) was missing from the sold code until September 5, 2026: without it, the divs stayed in flow, invisible.
The .trail-zone is 100% wide and 300px tall (radial gradient #1a1a3e → #0a0a1a, cursor: crosshair), with position: relative so it anchors the left/top values. It is overflow: hidden: falling particles are clipped at the edge — without that clipping, a particle emitted 6 px from the bottom edge fell up to 121 px below the zone (measured), over the neighboring content. The coordinates place the dot's top-left corner under the pointer, not its center. Finally, the mouseleave listener is an empty function: nothing is cleaned up on exit, the dots simply die on their own.
Accessibility
- prefers-reduced-motion not handled: the trail happens regardless of the system preference. Wrap the listener registration in
if (!matchMedia('(prefers-reduced-motion: reduce)').matches)— a CSS-only.trail-particle { display: none }would hide the dots but let the loop keep computing and recreating divs for nothing. - The effect is mouse-only: no
mousemovefrom the keyboard, and on touch the browser emits at most one on tap (two dots, gone at once). The zone is a non-focusabledivcarrying no information: keep it decorative, never place an instruction or content that only the trail would reveal. - Contrast: the shipped hint text,
rgba(255,255,255,.15)at 12.8 px, measures 1.5:1 on the background — readable by no one. If it must be read, switch torgba(255,255,255,.5)(5.3:1). The particles are non-text: the least contrasted,#6366f1, stays at 4.4:1 on#0a0a1a, above the 3:1 threshold for graphical elements. - Screen readers: the divs injected and removed up to 60 times per second are empty, hence silent, but they churn the accessibility tree. Put
aria-hidden="true"on the[data-trail-particles]zone so everything born inside is ignored as a block. The native pointer stays visible (cursor: crosshair, notcursor: none): no landmark is taken away from the user. - Integration: the zone must keep
position: relativeand an explicit height — withheight: 100%inside a parent without a height, it rendered at 0 px and the mouse never entered it; the sold code sets300px. The zone'soverflow: hiddenclips falling dots at the edge: keep it if elements sit below the zone, and only switch tovisibleto let the dots spill over an empty background. No reset is needed on exit: the loop stops by itself when the last particle dies.
Browser compatibility
ES5 code (var, function, no arrow functions): it only requires requestAnimationFrame, getBoundingClientRect, box-shadow and border-radius. Zero dependencies.
Without JavaScript, what remains is a radial-gradient zone and its hint text: nothing moves, nothing is missing from the content. Without box-shadow (very old browsers), the dots are sharp, with no glow.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="trail-zone" data-trail-particles>
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;color:rgba(255,255,255,0.15);font-size:0.8rem;">Move the cursor here</div>
</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 |
|---|---|---|
Particles per event (JS — f < 2) |
2 | Number of dots created on each mousemove. There is no rate limiter: at 120 events per second, 2 yields 240 dots per second. 1 halves the load; beyond 3, add a time threshold as in Particle Trail. |
Gravity (JS — e.vy += .05) |
.05 px/frame² | Vertical acceleration added on each frame. 0: dots float and drift; .15: they fall heavily. Negative, they fly up. |
Friction (JS — e.vx *= .99) |
.99 | Multiplies the horizontal velocity on each frame. .9 brakes the dots sharply, 1 lets them travel in a straight line. Does not apply vertically. |
Decay (JS — decay: .015 + .015 × Math.random()) |
.015 to .03 per frame | Sets the lifetime: 1 / decay frames, i.e. 0.55 to 1.1 s at 60 fps. Double both terms for a trail half as long. Size and opacity follow life, so decay also sets how fast the dot shrinks. |
Diameter (JS — size: 3 + 6 × Math.random()) |
3 to 9 px | Size at birth; the dot then measures size × life. The box-shadow glow is twice the diameter: bigger dots, bigger glow. |
Palette (JS — array a) |
#a78bfa, #6366f1, #8b5cf6, #c084fc, #e879f9, #818cf8 | Color drawn at random per dot, applied to the fill and the glow. A single entry = solid trail; check the contrast on your background (3:1 for a graphical element). |
Initial velocity (JS — 3 × (Math.random() − .5) and − 1) |
±1.5 px/frame, vertical bias −1 | The factor 3 sets the spread, the − 1 the initial upward push. − 3 makes the dots shoot up like a fountain; 0 lets them drop from the pointer. |
FAQ
mousemove with no limiter, and above all the rendering, which destroys and recreates every div on every frame (measured: no node kept between two frames). With 18 living dots, that's 18 removals and 18 creations 60 times per second. Two simple fixes: add a time threshold (if (Date.now() − last < 30) return) as in Particle Trail, and reuse the nodes — keep el in the particle object and only write style.left/top/width/height/opacity. The loop itself stops on its own as soon as the array is empty: at rest, the effect consumes nothing..trail-zone is overflow: hidden, and the dots gravity pulls down are clipped at the bottom edge — without that clipping, an emission 6 px from the edge fell up to 120 px below the zone (measured). If the zone has rounded corners, the clipping follows them. To let the dots spill out — over a full-screen hero, say — switch the zone to overflow: visible: since particles are pointer-events: none they intercept nothing, but they draw over the following elements.querySelectorAll('[data-trail-particles]') and gives each one its own array and loop. The whole page is possible by putting the attribute on a position: relative container that wraps it — but left/top are relative to that container, not to the viewport: if it scrolls, dots born at the top stay at the top. For a trail fixed on screen you would switch the particles to position: fixed and use clientX/clientY without subtracting the zone's rectangle.