← All gadgets

Particle Trail

Six kinds of particle chasing your cursor -- click to change breed

Running live from https://fun.whitneys.co, cross-origin and sandboxed.

Put it on your site

<iframe
  src="https://fun.whitneys.co/g/particle-trail"
  width="520"
  height="320"
  title="Particle Trail"
  sandbox="allow-scripts allow-popups-to-escape-sandbox"
  loading="lazy"
  style="border:0"></iframe>

An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.

The whole thing

Raw file

Every gadget is one file, and this is it -- all 12.6 kB. Take it, change it, ship it. Just keep the credit.

Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
  "name": "Particle Trail",
  "tagline": "Six kinds of particle chasing your cursor -- click to change breed",
  "category": "media",
  "tags": ["canvas", "particles", "animation", "cursor"],
  "order": 6,
  "author": "rwhitney",
  "background": "dark",
  "size": { "w": 520, "h": 320, "resizable": false },
  "config": {
    "kind":    { "type": "select", "label": "Particle",
                 "options": ["sparks", "bubbles", "confetti", "snow", "fireflies", "ink"],
                 "default": "sparks" },
    "accent":  { "type": "color",  "label": "Colour", "default": "#F92672" },
    "density": { "type": "range",  "label": "Density", "min": 1, "max": 12, "step": 1, "default": 4 },
    "scale":   { "type": "range",  "label": "Size", "min": 50, "max": 200, "step": 10, "default": 100 },
    "hud":     { "type": "bool",   "label": "Show the name", "default": true }
  },
  "permissions": []
}
</script>
<style>
  html, body { margin:0; height:100%; overflow:hidden; background:transparent;
               color:#F8F8F2; font:13px/1.4 ui-sans-serif, system-ui, sans-serif;
               user-select:none; -webkit-user-select:none; }
  #wrap { position:relative; width:100%; height:100%; touch-action:none; }
  canvas { display:block; width:100%; height:100%; }

  #hud {
    position:absolute; left:12px; bottom:11px;
    display:flex; align-items:center; gap:9px;
    pointer-events:none;
  }
  #name {
    font-weight:700; letter-spacing:.16em; text-transform:uppercase;
    font-size:11px; color:var(--accent, #F92672);
  }
  #dots { display:flex; gap:4px; }
  .dot { width:5px; height:5px; border-radius:50%;
         background:rgb(248 248 242 / .25); }
  .dot.on { background:var(--accent, #F92672); }

  #tip {
    position:absolute; right:12px; bottom:11px;
    font-size:10.5px; color:#A59F85; pointer-events:none;
  }

  #wrap:focus { outline:none; }
  #wrap:focus-visible { outline:2px solid #66D9EF; outline-offset:-2px; }
  .sr { position:absolute; width:1px; height:1px; overflow:hidden;
        clip-path:inset(50%); white-space:nowrap; }
</style>
</head>
<body>
  <div id="wrap" tabindex="0" role="application" aria-label="Particle trail">
    <canvas id="c"></canvas>
    <div id="hud"><span id="name"></span><span id="dots"></span></div>
    <div id="tip"></div>
    <div id="say" class="sr" role="status" aria-live="polite"></div>
  </div>

<script>
(function () {
  var cfg = window.gadget ? window.gadget.config : {};
  var accent = cfg.accent || '#F92672';
  var density = typeof cfg.density === 'number' ? cfg.density : 4;
  var scale = (typeof cfg.scale === 'number' ? cfg.scale : 100) / 100;
  var showHud = cfg.hud !== false;
  document.documentElement.style.setProperty('--accent', accent);

  var wrap = document.getElementById('wrap');
  var cv = document.getElementById('c');
  var ctx = cv.getContext('2d');

  /* ---- colour helpers -------------------------------------------------- */
  function rgb(hex) {
    var h = hex.replace('#', '');
    return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16),
            parseInt(h.slice(4, 6), 16)];
  }
  var A = rgb(accent);
  function rgba(a, shift) {
    var s = shift || 0;
    return 'rgba(' + Math.min(255, A[0] + s) + ',' + Math.min(255, A[1] + s) +
           ',' + Math.min(255, A[2] + s) + ',' + a + ')';
  }
  function rnd(a, b) { return a + Math.random() * (b - a); }

  /* ---- the array of particle types ------------------------------------
     Each entry owns its own spawn, step and draw. Adding a breed means
     pushing one object here -- nothing else in the file needs to know. */
  var TYPES = [
    {
      name: 'sparks',
      // Fast, short-lived, thrown outward and pulled down. Drawn as streaks
      // along their own velocity so speed reads visually.
      spawn: function (p, x, y) {
        var a = rnd(0, Math.PI * 2), s = rnd(1.5, 6);
        p.x = x; p.y = y;
        p.vx = Math.cos(a) * s; p.vy = Math.sin(a) * s - 1;
        p.life = p.max = rnd(24, 52);
        p.w = rnd(1, 2.2) * scale;
      },
      step: function (p) { p.vy += 0.16; p.vx *= 0.985; p.vy *= 0.985; },
      draw: function (p) {
        var t = p.life / p.max;
        ctx.strokeStyle = rgba(t, (1 - t) * 90);
        ctx.lineWidth = p.w * t;
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        ctx.lineTo(p.x - p.vx * 2.2, p.y - p.vy * 2.2);
        ctx.stroke();
      }
    },
    {
      name: 'bubbles',
      spawn: function (p, x, y) {
        p.x = x + rnd(-8, 8); p.y = y;
        p.vx = rnd(-0.3, 0.3); p.vy = rnd(-1.6, -0.5);
        p.life = p.max = rnd(60, 140);
        p.r = rnd(3, 11) * scale;
        p.phase = rnd(0, 6.3);
      },
      step: function (p) {
        p.phase += 0.06;
        p.x += Math.sin(p.phase) * 0.5;   // wobble as it rises
        p.vy *= 0.995;
      },
      draw: function (p) {
        var t = p.life / p.max;
        ctx.strokeStyle = rgba(t * 0.9);
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r, 0, 6.2832);
        ctx.stroke();
        // Highlight, so it reads as a sphere rather than a ring.
        ctx.fillStyle = rgba(t * 0.5, 120);
        ctx.beginPath();
        ctx.arc(p.x - p.r * 0.32, p.y - p.r * 0.32, p.r * 0.22, 0, 6.2832);
        ctx.fill();
      }
    },
    {
      name: 'confetti',
      spawn: function (p, x, y) {
        p.x = x; p.y = y;
        p.vx = rnd(-2.4, 2.4); p.vy = rnd(-3.5, -0.5);
        p.life = p.max = rnd(70, 150);
        p.w = rnd(4, 9) * scale; p.h = rnd(2, 4) * scale;
        p.rot = rnd(0, 6.3); p.spin = rnd(-0.22, 0.22);
        p.tint = rnd(-60, 90);
      },
      step: function (p) {
        p.vy += 0.13;
        p.vx *= 0.99;
        p.rot += p.spin;
        p.vx += Math.sin(p.rot) * 0.06;   // flutter
      },
      draw: function (p) {
        var t = p.life / p.max;
        ctx.save();
        ctx.translate(p.x, p.y);
        ctx.rotate(p.rot);
        // Squash on rotation fakes a flat sheet turning edge-on.
        ctx.scale(1, Math.abs(Math.cos(p.rot)) * 0.85 + 0.15);
        ctx.fillStyle = rgba(Math.min(1, t * 1.4), p.tint);
        ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
        ctx.restore();
      }
    },
    {
      name: 'snow',
      spawn: function (p, x, y) {
        p.x = x + rnd(-14, 14); p.y = y + rnd(-10, 10);
        p.vx = rnd(-0.35, 0.35); p.vy = rnd(0.25, 1.1);
        p.life = p.max = rnd(110, 240);
        p.r = rnd(1.2, 3.6) * scale;
        p.phase = rnd(0, 6.3);
      },
      step: function (p) {
        p.phase += 0.02;
        p.x += Math.sin(p.phase) * 0.35;
      },
      draw: function (p) {
        var t = p.life / p.max;
        ctx.fillStyle = rgba(Math.min(1, t * 1.6), 140);
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r, 0, 6.2832);
        ctx.fill();
      }
    },
    {
      name: 'fireflies',
      spawn: function (p, x, y) {
        p.x = x + rnd(-18, 18); p.y = y + rnd(-18, 18);
        p.vx = rnd(-0.6, 0.6); p.vy = rnd(-0.6, 0.6);
        p.life = p.max = rnd(90, 200);
        p.r = rnd(1.4, 3) * scale;
        p.phase = rnd(0, 6.3);
      },
      step: function (p) {
        // Wander: nudge the heading rather than the position, so the path
        // curves instead of jittering.
        p.vx += rnd(-0.07, 0.07); p.vy += rnd(-0.07, 0.07);
        p.vx *= 0.96; p.vy *= 0.96;
        p.phase += 0.11;
      },
      draw: function (p) {
        var t = p.life / p.max;
        var pulse = 0.45 + 0.55 * Math.abs(Math.sin(p.phase));
        var glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 7);
        glow.addColorStop(0, rgba(t * pulse, 110));
        glow.addColorStop(1, rgba(0));
        ctx.fillStyle = glow;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r * 7, 0, 6.2832);
        ctx.fill();
      }
    },
    {
      name: 'ink',
      spawn: function (p, x, y) {
        p.x = x; p.y = y;
        p.vx = rnd(-0.5, 0.5); p.vy = rnd(-0.5, 0.5);
        p.life = p.max = rnd(50, 110);
        p.r = rnd(6, 20) * scale;
      },
      step: function (p) { p.r *= 1.028; p.vx *= 0.94; p.vy *= 0.94; },
      draw: function (p) {
        var t = p.life / p.max;
        ctx.fillStyle = rgba(t * t * 0.5);
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r, 0, 6.2832);
        ctx.fill();
      }
    }
  ];

  var index = Math.max(0, TYPES.map(function (t) { return t.name; })
    .indexOf(cfg.kind || 'sparks'));

  /* ---- pool ------------------------------------------------------------
     Fixed-size ring. Particles are never allocated after startup -- an
     emitter tied to pointer speed can otherwise grow without bound and take
     the frame rate with it. */
  var MAX = 900;
  var pool = new Array(MAX);
  for (var i = 0; i < MAX; i++) pool[i] = { life: 0 };
  var head = 0;

  function emit(x, y) {
    var n = density;
    while (n--) {
      var p = pool[head];
      head = (head + 1) % MAX;
      TYPES[index].spawn(p, x, y);
    }
  }

  /* ---- canvas ---------------------------------------------------------- */
  var W = 0, H = 0, dpr = 1;
  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = wrap.clientWidth; H = wrap.clientHeight;
    cv.width = Math.round(W * dpr);
    cv.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resize();
  if (window.ResizeObserver) new ResizeObserver(resize).observe(wrap);

  var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  var mx = -999, my = -999, hasPointer = false;

  function frame() {
    // Fade rather than clear, so everything leaves a trail for free.
    ctx.globalCompositeOperation = 'source-over';
    ctx.fillStyle = 'rgba(20,21,15,0.22)';
    ctx.fillRect(0, 0, W, H);
    ctx.globalCompositeOperation = 'lighter';

    var type = TYPES[index];
    for (var i = 0; i < MAX; i++) {
      var p = pool[i];
      if (p.life <= 0) continue;
      p.life--;
      type.step(p);
      p.x += p.vx; p.y += p.vy;
      if (p.x < -60 || p.x > W + 60 || p.y < -60 || p.y > H + 60) {
        p.life = 0;
        continue;
      }
      type.draw(p);
    }
    ctx.globalCompositeOperation = 'source-over';
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  /* ---- input ----------------------------------------------------------- */
  function setIndex(i) {
    index = ((i % TYPES.length) + TYPES.length) % TYPES.length;
    paintHud();
    document.getElementById('say').textContent = TYPES[index].name;
  }

  function paintHud() {
    if (!showHud) return;
    document.getElementById('name').textContent = TYPES[index].name;
    var dots = document.getElementById('dots');
    dots.innerHTML = '';
    TYPES.forEach(function (t, i) {
      var d = document.createElement('span');
      d.className = 'dot' + (i === index ? ' on' : '');
      dots.appendChild(d);
    });
  }

  wrap.addEventListener('pointermove', function (e) {
    var r = cv.getBoundingClientRect();
    var x = e.clientX - r.left, y = e.clientY - r.top;
    if (hasPointer && !reduce) {
      // Emit along the segment travelled, or fast movement leaves gaps.
      var dx = x - mx, dy = y - my;
      var steps = Math.min(6, Math.max(1, Math.round(Math.hypot(dx, dy) / 9)));
      for (var s = 1; s <= steps; s++) {
        emit(mx + dx * (s / steps), my + dy * (s / steps));
      }
    }
    mx = x; my = y; hasPointer = true;
  });

  wrap.addEventListener('pointerleave', function () { hasPointer = false; });

  /* Click cycles the breed. Tap, specifically -- not drag.
   *
   * On touch, drawing REQUIRES dragging, so cycling on pointerdown would
   * change the particle out from under you on every stroke. A tap is
   * "barely moved, released quickly"; anything else is someone drawing.
   *
   * Note there is deliberately NO wheel handler. A gadget that swallows the
   * wheel is a trap on someone else's page -- you scroll past it, the page
   * stops moving, and you cannot tell why. Click costs nothing and steals
   * nothing. */
  var tapX = 0, tapY = 0, tapT = 0;

  wrap.addEventListener('pointerdown', function (e) {
    e.preventDefault();
    wrap.focus();
    tapX = e.clientX; tapY = e.clientY; tapT = Date.now();
  });

  wrap.addEventListener('pointerup', function (e) {
    var moved = Math.hypot(e.clientX - tapX, e.clientY - tapY);
    if (moved < 8 && Date.now() - tapT < 350) setIndex(index + 1);
  });

  wrap.addEventListener('keydown', function (e) {
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      e.preventDefault(); setIndex(index + 1);
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      e.preventDefault(); setIndex(index - 1);
    }
  });

  document.getElementById('tip').textContent = 'click to change particle';
  paintHud();
})();
</script>
</body>
</html>