Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Glowing particles from the cursor" for my site using the Enrichr Library element at https://enrichr.dev/elements/particles-cursor.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/particles-cursor.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- color: #e6ff55
- rate: 4
- gravity: 0.15
- size: 5
- life: 1.4
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/particles-cursor.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-pcu">
<div class="ef-pcu__wrap" tabindex="0" role="group" aria-label="Particle playground. Move the pointer or use the arrow keys to emit particles.">
<canvas class="ef-pcu__cv" aria-hidden="true"></canvas>
<span class="ef-pcu__hint" aria-hidden="true">Move your pointer</span>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-pcu { width: 100%; }
.ef-pcu__wrap { --ef-acc: #e6ff55; position: relative; box-sizing: border-box; width: 100%; height: 20rem; max-height: 100%; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; cursor: crosshair; outline: none; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-pcu__wrap:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc); }
.ef-pcu__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-pcu__hint { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); color: #fbf8f1; font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.55; pointer-events: none; transition: opacity 400ms; }
.ef-pcu__wrap.is-used .ef-pcu__hint { opacity: 0; }JavaScript
// Enrichr Library · https://enrichr.dev
// Particles cursor: a canvas 2D particle system. Moving the pointer (mouse, pen or touch) emits glowing particles along the path,
// each with its own velocity (inherits some pointer speed), gravity, drag and a fade over its lifetime. Drawn additively for the glow.
// The loop only runs while particles are alive and the element is on screen (IntersectionObserver) and the tab is visible.
// Keyboard: focus the box and use the arrow keys to move a virtual emitter. Reduced motion: short-lived, still, faint particles.
// mount(root, { background, color, rate (1-12 per move), gravity (-0.2-0.6), size (1-12 px), life (0.4-3 s) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-pcu__wrap");
var cv = root.querySelector(".ef-pcu__cv");
var ctx = cv.getContext("2d");
var opts = {}, parts = [], W = 0, H = 0, dpr = 1, raf = 0, last = 0, visible = true, inView = true;
var px = -1, py = -1, kx = 0, ky = 0, kvx = 0, kvy = 0, rgb = [230, 255, 85];
var MAX = 700;
function hex(c) {
var m = /^#?([0-9a-f]{6})$/i.exec(c || "");
if (!m) return [230, 255, 85];
var n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function size() {
var r = wrap.getBoundingClientRect();
W = Math.max(1, r.width); H = Math.max(1, r.height);
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function emit(x, y, vx, vy, n) {
var k = reduce ? 0.3 : 1;
n = Math.max(1, Math.round(n * k));
for (var i = 0; i < n && parts.length < MAX; i++) {
var a = Math.random() * Math.PI * 2, s = (reduce ? 8 : 30 + Math.random() * 90);
var life = opts.life * (0.6 + Math.random() * 0.6) * (reduce ? 0.5 : 1);
parts.push({
x: x, y: y,
vx: reduce ? 0 : vx * 0.25 + Math.cos(a) * s,
vy: reduce ? 0 : vy * 0.25 + Math.sin(a) * s - 20,
life: life, max: life,
r: opts.size * (0.5 + Math.random() * 0.8)
});
}
start();
}
function frame(t) {
raf = 0;
var dt = Math.min(0.05, (t - last) / 1000 || 0.016);
last = t;
// keyboard emitter
if (kvx || kvy) {
kx = Math.max(0, Math.min(W, kx + kvx * 220 * dt));
ky = Math.max(0, Math.min(H, ky + kvy * 220 * dt));
emit(kx, ky, kvx * 120, kvy * 120, Math.ceil(opts.rate / 3));
}
ctx.globalCompositeOperation = "source-over";
ctx.clearRect(0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
var g = opts.gravity * 600, drag = Math.pow(0.35, dt);
for (var i = parts.length - 1; i >= 0; i--) {
var p = parts[i];
p.life -= dt;
if (p.life <= 0) { parts.splice(i, 1); continue; }
if (!reduce) {
p.vy += g * dt; p.vx *= drag; p.vy *= drag;
p.x += p.vx * dt; p.y += p.vy * dt;
}
var k = p.life / p.max, rad = p.r * (0.4 + 0.6 * k) * 3;
var al = (reduce ? 0.35 : 0.85) * k;
var gr = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, rad);
gr.addColorStop(0, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + al.toFixed(3) + ")");
gr.addColorStop(1, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0)");
ctx.fillStyle = gr;
ctx.fillRect(p.x - rad, p.y - rad, rad * 2, rad * 2);
}
if (parts.length || kvx || kvy) start();
}
function start() {
if (!raf && inView && visible) { last = performance.now(); raf = requestAnimationFrame(frame); }
}
function stop() { if (raf) { cancelAnimationFrame(raf); raf = 0; } }
function pos(e) {
var r = wrap.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function onMove(e) {
var p = pos(e);
wrap.classList.add("is-used");
if (px >= 0) {
var dx = p[0] - px, dy = p[1] - py, d = Math.sqrt(dx * dx + dy * dy);
var steps = Math.max(1, Math.min(6, Math.floor(d / 14)));
for (var i = 1; i <= steps; i++) emit(px + dx * i / steps, py + dy * i / steps, dx * 40, dy * 40, Math.ceil(opts.rate / steps));
} else emit(p[0], p[1], 0, 0, opts.rate);
px = p[0]; py = p[1];
}
function onDown(e) { var p = pos(e); wrap.classList.add("is-used"); emit(p[0], p[1], 0, 0, opts.rate * 5); px = p[0]; py = p[1]; }
function onLeave() { px = -1; py = -1; }
function onKey(e) {
var v = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!v) return;
e.preventDefault();
wrap.classList.add("is-used");
if (!kx && !ky) { kx = W / 2; ky = H / 2; }
kvx = v[0]; kvy = v[1];
start();
}
function onKeyUp(e) { if (e.key.indexOf("Arrow") === 0) { kvx = 0; kvy = 0; } }
function onBlur() { kvx = 0; kvy = 0; }
function onVis() { visible = !document.hidden; if (visible) start(); else stop(); }
var io = null;
if (typeof IntersectionObserver !== "undefined") {
io = new IntersectionObserver(function (en) {
inView = en[0].isIntersecting;
if (inView) start(); else stop();
});
io.observe(wrap);
}
var ro = null;
if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(size); ro.observe(wrap); }
else window.addEventListener("resize", size);
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerdown", onDown);
wrap.addEventListener("pointerleave", onLeave);
wrap.addEventListener("keydown", onKey);
wrap.addEventListener("keyup", onKeyUp);
wrap.addEventListener("blur", onBlur);
document.addEventListener("visibilitychange", onVis);
function update(next) {
opts = Object.assign({ background: "#0c1e18", color: "#e6ff55", rate: 4, gravity: 0.15, size: 5, life: 1.4 }, opts, next || {});
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.color);
rgb = hex(opts.color);
}
update(o);
size();
return {
update: update,
destroy: function () {
stop();
if (io) io.disconnect();
if (ro) ro.disconnect(); else window.removeEventListener("resize", size);
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerdown", onDown);
wrap.removeEventListener("pointerleave", onLeave);
wrap.removeEventListener("keydown", onKey);
wrap.removeEventListener("keyup", onKeyUp);
wrap.removeEventListener("blur", onBlur);
document.removeEventListener("visibilitychange", onVis);
parts.length = 0;
ctx.clearRect(0, 0, W, H);
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"color":"#e6ff55","rate":4,"gravity":0.15,"size":5,"life":1.4});How it works
Pointer events on the box are converted to canvas coordinates. For each move the script walks the segment from the last position in small steps so fast strokes stay continuous, and spawns a few particles at each step. A particle carries position, velocity, remaining life and radius; velocity starts as a random burst plus a quarter of the pointer's own velocity. Each frame applies gravity and drag with the frame delta, ages the particle, and draws it as a radial gradient whose size and alpha shrink with the life left. Drawing uses the lighter composite mode so overlapping particles add up to a glow. The canvas is sized by device pixel ratio, capped at 2.
Accessibility and performance
The box is focusable and labelled, and the arrow keys move a virtual emitter so the effect is not mouse-only. The canvas is decorative and hidden from assistive technology. With reduced motion, particles do not travel: they appear faint, fewer and short-lived at the pointer. The canvas uses touch-action pan-y, so vertical scrolling still works on touch screens. Do not put essential content on top of the canvas.
The loop only runs while particles exist, the element is in view (IntersectionObserver) and the tab is visible, and it cancels its requestAnimationFrame otherwise. The particle count is capped at 700 and the canvas resolution at 2x. Per-particle radial gradients are fine at this scale; for thousands of particles, pre-render one sprite and use drawImage.
| Section | Background |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, CSS, Canvas 2D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Particles Cursor by Codrops on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Can I make it follow the cursor over the whole page?
Yes. Attach the pointer listeners to window and make the canvas fixed and full-screen with pointer-events none. Keep the same emit and frame code.
How do I change the shape of the particles?
Replace the radial gradient fill in the frame function with a small drawImage sprite, a star path or a line drawn along the velocity.
Why do particles rise at first?
Each particle starts with a small upward kick, then gravity pulls it down. Set gravity to a negative value to make particles float upwards like embers.