Copied!
Background

Glowing particles from the cursor

A canvas 2D particle system that emits glowing particles from the pointer with velocity, gravity and fade, for mouse, pen and touch, and pauses when off-screen.

← Library

Element · Vanilla JS + CSS + Canvas 2DUpdated November 6, 2026

BackgroundElementVanilla JS + CSS + Canvas 2D

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.

SectionBackground
TechniquesPointer interaction
StackVanilla JS, CSS, Canvas 2D
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byParticles 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.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

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.