← Particles

Paper Streamers

Curly paper streamers shoot up from the bottom corners, curling as they fly, then hang, twist and flutter down. Click anywhere inside to fire a few more from that spot.

Click anywhere

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-paper-streamers to a section, hero or card. It fires once when it scrolls into view, and again on every click.

Options

Add any of these attributes to the same element.

data-loopFire again every few seconds while it’s in view.
data-intervalTime between bursts with data-loop. Default 3200 ms.
data-countStreamers per burst. Default 14.
data-from="bottom"Fire a fan from the bottom centre instead of the two corners.
data-colorsComma-separated colours.
data-click="false"Don’t fire on click.

Each streamer is a strip of paper with a front and a darker back, so you see it twist as it falls. It’s drawn on a transparent canvas behind your content. With reduced motion you get a still frame. From code, animacious.streamers(section) fires a burst.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Paper Streamers: curly paper streamers shoot up out of the bottom corners
    // of a section, curling as they fly, then hang, twist and flutter down. Click
    // anywhere inside to fire a few from that spot. Drawn on a transparent
    // canvas behind your content.
    // Usage: <section data-paper-streamers>…</section>
    // Optional: data-loop (fire again every few seconds while in view),
    // data-interval (ms between bursts with data-loop, default 3200),
    // data-count (streamers per burst, default 14), data-from ("corners",
    // default, or "bottom" for a fan from the bottom centre),
    // data-colors="#a855f7,#facc15,…", data-click="false" (no click bursts).
    // Script use: animacious.streamers(element) fires a burst.
    var DEFAULTS = {
      interval: 3200,
      count: 14,
      colors: ['#a855f7', '#ec4899', '#facc15', '#38bdf8', '#22c55e', '#f97316']
    };

    var css =
      '[data-paper-streamers]{position:relative;isolation:isolate;overflow:hidden}' +
      '.pst-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 reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // A darker version of a colour, for the back of the paper.
    function shade(hex, f) {
      var m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
      if (!m) return hex;
      var s = m[1];
      if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
      var n = parseInt(s, 16);
      return 'rgb(' + Math.round((n >> 16) * f) + ',' + Math.round(((n >> 8) & 255) * f) + ',' + Math.round((n & 255) * f) + ')';
    }

    var SEGMENTS = 18;
    var GRAVITY = 500;   // px/s²
    var DRAG = 1.6;      // per second, on the way up
    var FALL = 70;       // px/s, how fast paper drifts down

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'pst-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var colors = (d.colors ? d.colors.split(',') : DEFAULTS.colors).map(function (c) {
        return { front: c.trim(), back: shade(c, 0.7) };
      });
      var count = +d.count || DEFAULTS.count;
      var from = d.from || 'corners';
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, scale = 1, t = 0;
      var streamers = [];

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        // Small boxes get shorter, slower streamers, so they stay inside.
        scale = Math.max(0.55, Math.min(1.25, h / 360));
      }

      // One streamer, fired from (x, y) at an angle (radians, -PI/2 is up).
      function streamer(x, y, angle) {
        var speed = R(820, 1050) * scale;
        var pts = [];
        for (var i = 0; i < SEGMENTS; i++) pts.push({ x: x, y: y, px: x, py: y });
        var c = colors[Math.floor(Math.random() * colors.length)];
        streamers.push({
          pts: pts,
          vx: Math.cos(angle) * speed,
          vy: Math.sin(angle) * speed,
          bx: x, by: y,
          curl: R(9, 14) * (Math.random() < 0.5 ? -1 : 1),   // rad/s: how fast it loops
          loop: R(8, 15) * scale,                             // px: how big the curls are
          spinAngle: Math.random() * 6.28,
          seg: R(7, 10) * scale,
          width: R(5, 8) * Math.max(0.75, scale),
          twist: R(0.35, 0.7, 2),     // twist per segment
          spin: R(3, 7) * (Math.random() < 0.5 ? -1 : 1),
          phase: Math.random() * 6.28,
          flutter: R(2, 4, 2),
          age: 0,
          life: R(5, 7),
          front: c.front,
          back: c.back
        });
      }

      function burst(x, y) {
        var n = count, i;
        if (x !== undefined) {
          n = Math.max(3, Math.round(count / 3));
          for (i = 0; i < n; i++) streamer(x, y, -Math.PI / 2 + R(-0.6, 0.6, 2));
        } else if (from === 'bottom') {
          for (i = 0; i < n; i++) streamer(w / 2 + R(-10, 10), h + 4, -Math.PI / 2 + R(-0.7, 0.7, 2));
        } else {
          for (i = 0; i < n; i++) {
            var left = i % 2 === 0;
            // Aim up and in, steeper in tall boxes so they don't hit the far side.
            var wide = Math.max(0.3, Math.min(1, w / h / 2.4));
            var tilt = R(0.2, 0.55, 2) * wide;
            streamer(left ? -4 : w + 4, h + 4, left ? -Math.PI / 2 + tilt : -Math.PI / 2 - tilt);
          }
        }
        wake();
      }

      function advance(dt) {
        t += dt;
        for (var s = streamers.length - 1; s >= 0; s--) {
          var st = streamers[s], p = st.pts, head = p[0];
          st.age += dt;
          // The head flies on a simple arc: fast and slowed by the air on
          // the way up, then drifting down slowly like paper does.
          if (st.vy < 0) {
            st.vy += GRAVITY * scale * dt;
            var k = Math.exp(-DRAG * dt);
            st.vx *= k;
            st.vy *= k;
          } else {
            st.vy += (FALL * scale - st.vy) * (1 - Math.exp(-3 * dt));
            st.vx *= Math.exp(-2 * dt);
            // Falling paper flutters from side to side.
            st.vx += Math.sin(t * st.flutter + st.phase) * 120 * scale * dt;
          }
          st.bx += st.vx * dt;
          st.by += st.vy * dt;
          // It loops round that arc as it goes, which leaves the curls in the
          // paper behind it: stretched out while fast, tight once it slows.
          st.spinAngle += st.curl * dt;
          head.px = head.x; head.py = head.y;
          head.x = st.bx + Math.cos(st.spinAngle) * st.loop;
          head.y = st.by + Math.sin(st.spinAngle) * st.loop;
          // The rest of the paper: loose points with a little weight,
          // each held a fixed distance behind the one in front.
          var g = GRAVITY * 0.35 * scale * dt * dt;
          for (var i = 1; i < p.length; i++) {
            var q = p[i], ox = q.x, oy = q.y;
            q.x += (q.x - q.px) * 0.94;
            q.y += (q.y - q.py) * 0.94 + g;
            q.px = ox; q.py = oy;
            var f = p[i - 1], dx = q.x - f.x, dy = q.y - f.y, dist = Math.sqrt(dx * dx + dy * dy);
            if (dist > st.seg) {
              q.x = f.x + dx / dist * st.seg;
              q.y = f.y + dy / dist * st.seg;
            }
          }
          var lowest = Infinity;
          for (i = 0; i < p.length; i++) lowest = Math.min(lowest, p[i].y);
          if (st.age > st.life + 0.8 || (st.age > 1 && lowest > h + 20)) streamers.splice(s, 1);
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        for (var s = 0; s < streamers.length; s++) {
          var st = streamers[s], p = st.pts;
          ctx.globalAlpha = st.age > st.life ? Math.max(0, 1 - (st.age - st.life) / 0.8) : 1;
          // Left and right edges of the strip at every point, its width
          // following the twist, so you see the face, then the edge, then the back.
          var L = [], Rt = [], side = [];
          for (var i = 0; i < p.length; i++) {
            var a = p[Math.max(0, i - 1)], b = p[Math.min(p.length - 1, i + 1)];
            var dx = b.x - a.x, dy = b.y - a.y, len = Math.sqrt(dx * dx + dy * dy) || 1;
            var tw = Math.cos(st.phase + i * st.twist + t * st.spin);
            var half = st.width * (0.12 + 0.88 * Math.abs(tw)) / 2;
            var nx = -dy / len * half, ny = dx / len * half;
            L.push(p[i].x + nx, p[i].y + ny);
            Rt.push(p[i].x - nx, p[i].y - ny);
            side.push(tw >= 0);
          }
          for (i = 0; i < p.length - 1; i++) {
            var j = i * 2, k = j + 2;
            ctx.fillStyle = side[i] ? st.front : st.back;
            ctx.beginPath();
            ctx.moveTo(L[j], L[j + 1]);
            ctx.lineTo(L[k], L[k + 1]);
            ctx.lineTo(Rt[k], Rt[k + 1]);
            ctx.lineTo(Rt[j], Rt[j + 1]);
            ctx.closePath();
            ctx.fill();
          }
        }
        ctx.globalAlpha = 1;
      }

      var last = 0, visible = false;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) / 1000 : 1 / 60;
          last = now;
          advance(dt);
          draw();
          if (!streamers.length && !d.hasOwnProperty('loop')) timer.pause();
        }
      });
      function wake() { if (visible) { last = 0; timer.play(); } }

      resize();
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);

      if (reduced()) {
        // A still frame: a burst caught mid-air.
        burst();
        for (var i = 0; i < 80; i++) advance(1 / 60);
        draw();
        return;
      }

      if (d.click !== 'false') {
        el.addEventListener('click', function (e) {
          var r = el.getBoundingClientRect();
          burst(e.clientX - r.left, e.clientY - r.top);
        });
      }
      el._pstBurst = function () { burst(); };

      var interval = +d.interval || DEFAULTS.interval, loopTimer = null, fired = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) {
          if (!fired) { fired = true; burst(); }
          if (streamers.length || d.hasOwnProperty('loop')) wake();
          if (d.hasOwnProperty('loop') && !loopTimer) loopTimer = setInterval(burst, interval);
        } else {
          timer.pause();
          clearInterval(loopTimer);
          loopTimer = null;
        }
      }, { threshold: 0.2 }).observe(el);
    }

    window.animacious = window.animacious || {};
    window.animacious.streamers = function (el) { if (el && el._pstBurst) el._pstBurst(); };

    function init() {
      document.querySelectorAll('[data-paper-streamers]').forEach(function (el) {
        if (el._pst) return;
        el._pst = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-paper-streamers>
  <h1>You did it!</h1>
</section>