Cursors✨ Premium

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.

JSCanvasrequestAnimationFrame

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

Move the mouse — hero of a motion design studio — Trail Particles example 1

① Move the mouse — hero of a motion design studio

WhenHome page of a creative studio: headline, subtitle and one button, on a dark background filling the screen.
WhyThe falling trail illustrates the trade — motion — without video or a library. On a full-screen hero the zone is large and the fall below the pointer has room; the text stays readable because the dots are small and die within a second.
SettingsPalette cut down to the brand's two shades, one particle per event (f < 1) to lighten the load, decay .02 + .02 × Math.random() for a shorter trail (0.4 to 0.8 s).
Whiteboard — drawing or brainstorming app — Trail Particles example 2

② Whiteboard — drawing or brainstorming app

WhenCanvas of a collaborative app, framed by a header and a toolbar.
WhyThe dot following the stylus gives immediate feedback on an otherwise empty surface without getting in the way: particles are pointer-events: none, the click goes through to the canvas. Here overflow: hidden on the zone is essential, since the toolbar borders it.
SettingsDiameter 2 to 5 px (2 + 3 × Math.random()), gravity removed (vy += 0) so dots float instead of falling, friction .95 so they stop quickly.
Title screen — game or playful app — Trail Particles example 3

③ Title screen — game or playful app

WhenHome screen of a game: logo, a “New game” label, score and level in the corners, space backdrop.
WhySparks that follow the hand before the first click say “this reacts”: that is the promise of a game. The purples and the glow suit a night setting; gravity makes the dots fall back like embers.
SettingsThree particles per event (f < 3), diameter 4 to 12 px, gravity .1 for a decisive fall, white added to the palette for sparks.

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 mousemove from the keyboard, and on touch the browser emits at most one on tap (two dots, gone at once). The zone is a non-focusable div carrying 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 to rgba(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, not cursor: none): no landmark is taken away from the user.
  • Integration: the zone must keep position: relative and an explicit height — with height: 100% inside a parent without a height, it rendered at 0 px and the mouse never entered it; the sold code sets 300px. The zone's overflow: hidden clips falling dots at the edge: keep it if elements sit below the zone, and only switch to visible to 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.

Chrome 24+✓ Full
Firefox 23+✓ Full
Safari 6.1+✓ Full
Edge 12+✓ Full
Mobile iOS✓ Static (no mousemove)
Android Chrome✓ Static (no mousemove)

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):

index.html — structure
<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>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
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

Two things cost: creating two dots per 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.
Not with the shipped code: .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.
Several zones, yes, with no change: the script runs 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.