← Particles

Particle Fountain

A jet of glowing droplets sprays up from a nozzle at the bottom, arcs over under gravity and splashes down, with a few droplets skipping where they land. Move the pointer or drag a finger to tilt the jet.

Point to tilt

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-particle-fountain to a section with a dark background. Add data-autoplay to have the jet sway by itself whenever nobody is pointing.

Options

Add any of these attributes to the same element.

data-autoplayThe jet sways gently by itself when idle. Without it, it points straight up.
data-rateParticles per second. Default 260.
data-heightHow high it shoots, as a share of the section’s height. Default 0.75.
data-spreadWidth of the spray, in degrees. Default 14.
data-colorsComma-separated colours.

It’s drawn on a transparent canvas behind your content and paused when off screen. On a phone the jet follows your finger and goes back to swaying a moment after you let go. With reduced motion you see a still frame.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Particle Fountain: a jet of glowing particles sprays up from a nozzle at
    // the bottom of a section, arcs over under gravity and splashes down, with
    // little droplets bouncing where it lands. Point (or drag a finger) to tilt
    // the jet; with data-autoplay it sways gently by itself when idle. Drawn on
    // a canvas behind your content, and paused when off screen.
    // Usage: <section data-particle-fountain>…</section>
    // Optional: data-autoplay (sway by itself when nobody is pointing),
    // data-rate (particles per second, default 260), data-height (how high it
    // shoots, as a share of the section's height, default 0.75), data-spread
    // (width of the spray in degrees, default 14), data-colors (comma-separated).
    var DEFAULTS = { rate: 260, height: 0.75, spread: 14, colors: ['#67e8f9', '#38bdf8', '#a78bfa', '#f0abfc', '#ffffff'] };

    var css =
      '[data-particle-fountain]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent;touch-action:pan-y}' +
      '.pfn-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'pfn-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var rate = +d.rate || DEFAULTS.rate;
      var heightShare = +d.height || DEFAULTS.height;
      var spread = (d.spread !== undefined ? +d.spread : DEFAULTS.spread) * Math.PI / 180;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var w = 0, h = 0, dpr = 1, t = 0, owed = 0;
      var parts = [];
      var tilt = 0, aim = 0, pointerUntil = -1;
      var G = 0.0012; // px per ms², scaled to the height below

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
      }
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      function g() { return G * Math.max(0.35, h / 600); }

      function emit() {
        var gg = g();
        // Speed needed to reach the chosen height, with a little variety.
        var v = Math.sqrt(2 * gg * h * heightShare) * (0.82 + Math.random() * 0.2);
        var a = -Math.PI / 2 + tilt + (Math.random() - 0.5) * spread;
        parts.push({
          x: w / 2 + (Math.random() - 0.5) * 6, y: h - 4,
          vx: Math.cos(a) * v, vy: Math.sin(a) * v,
          r: 1 + Math.random() * 1.8, c: colors[(Math.random() * colors.length) | 0],
          age: 0, bounces: 0
        });
      }

      function step(dt) {
        t += dt;
        if (t > pointerUntil) {
          aim = autoplay ? Math.sin(t * 0.0007) * 0.38 + Math.sin(t * 0.0019) * 0.1 : 0;
        }
        tilt += (aim - tilt) * Math.min(1, dt * 0.004);
        owed += rate * dt / 1000;
        var cap = Math.min(1600, rate * 4);
        while (owed >= 1) { owed--; if (parts.length < cap) emit(); }
        var gg = g();
        for (var i = parts.length - 1; i >= 0; i--) {
          var p = parts[i];
          p.age += dt;
          p.vy += gg * dt;
          p.x += p.vx * dt;
          p.y += p.vy * dt;
          if (p.y > h - 2 && p.vy > 0) {
            // Splash: bounce a little, lose most of the energy, then go.
            if (p.bounces < 2 && Math.random() < 0.6) {
              p.y = h - 2;
              p.vy *= -(0.25 + Math.random() * 0.2);
              p.vx = p.vx * 0.6 + (Math.random() - 0.5) * 0.15;
              p.bounces++;
            } else { parts.splice(i, 1); continue; }
          }
          if (p.x < -20 || p.x > w + 20) parts.splice(i, 1);
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        // The pool glow where it lands.
        var gl = ctx.createRadialGradient(w / 2, h, 0, w / 2, h, Math.min(w, 420) * 0.6);
        gl.addColorStop(0, 'rgba(103,232,249,.22)');
        gl.addColorStop(1, 'rgba(103,232,249,0)');
        ctx.fillStyle = gl;
        ctx.fillRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        ctx.lineCap = 'round';
        for (var i = 0; i < parts.length; i++) {
          var p = parts[i];
          ctx.globalAlpha = p.bounces ? 0.55 : 0.9;
          ctx.strokeStyle = p.c;
          ctx.lineWidth = p.r * 1.6;
          ctx.beginPath();
          ctx.moveTo(p.x - p.vx * 18, p.y - p.vy * 18);
          ctx.lineTo(p.x, p.y);
          ctx.stroke();
        }
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'source-over';
        // The nozzle.
        ctx.save();
        ctx.translate(w / 2, h);
        ctx.rotate(tilt);
        ctx.fillStyle = 'rgba(255,255,255,.85)';
        ctx.beginPath();
        ctx.moveTo(-7, 0); ctx.lineTo(-3.5, -12); ctx.lineTo(3.5, -12); ctx.lineTo(7, 0);
        ctx.fill();
        ctx.restore();
      }

      function point(e, hold) {
        var r = el.getBoundingClientRect();
        var x = e.clientX - r.left, y = e.clientY - r.top;
        aim = Math.max(-0.7, Math.min(0.7, Math.atan2(x - w / 2, Math.max(40, h - y))));
        pointerUntil = t + hold;
      }

      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        // A still frame mid-spray.
        for (var k = 0; k < 160; k++) step(16);
        draw();
        return;
      }
      for (var warm = 0; warm < 60; warm++) step(16);

      el.addEventListener('pointermove', function (e) { point(e, e.pointerType === 'mouse' ? 1500 : 2500); });
      el.addEventListener('pointerdown', function (e) { point(e, 2500); });

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(40, now - last) : 16;
          last = now;
          step(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-particle-fountain]').forEach(function (el) {
        if (el._pfn) return;
        el._pfn = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-particle-fountain data-autoplay style="background: #08152a; color: #fff">
  <h1>Make a splash</h1>
</section>