← Backgrounds

Vortex Tunnel

Streaks of light spiral away from you into an endless tunnel, whirling faster as they shrink towards its glowing far end, while the tunnel gently sways and bends.

Into the vortex

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-vortex-tunnel to a section with a dark background.

Options

Add any of these attributes to the same element.

data-colorsComma-separated colours, any number. The first also lights the far end. Default violet, pink and sky blue.
data-densityHow many particles, as a multiplier. Default 1.
data-speedSpeed multiplier. Default 1.
data-twistHow hard it spins. Default 1; a negative number spins the other way, and 0 flies straight in.

It’s drawn on a transparent canvas behind your content, with the particle count following the section’s size, and paused when off screen. With reduced motion it shows one still frame.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Vortex Tunnel: streaks of light spiral away from you into an endless
    // tunnel, whirling faster as they shrink towards its far end, while the
    // tunnel itself sways gently. Drawn on a transparent canvas behind your
    // content, and paused when off screen.
    // Usage: <section data-vortex-tunnel style="background:#07040f">…</section>
    // Optional: data-colors (comma-separated, default violet, pink and sky
    // blue), data-density (how many particles, as a multiplier, default 1),
    // data-speed (default 1), data-twist (how hard it spins, default 1; a
    // negative number spins the other way).
    var DEFAULTS = { colors: '#a855f7,#ec4899,#38bdf8', density: 1, speed: 1, twist: 1 };

    var css =
      '[data-vortex-tunnel]{position:relative;isolation:isolate;overflow:hidden}' +
      '.vtx-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);

    var TAU = Math.PI * 2;
    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c.trim();
      var v = probe.fillStyle;
      if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
      return v.match(/[\d.]+/g).slice(0, 3).map(Number);
    }

    var ZFAR = 14;

    function build(el) {
      var d = el.dataset;
      var cols = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return rgb(c).join(','); });
      var density = d.density !== undefined ? +d.density : DEFAULTS.density;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var twist = d.twist !== undefined ? +d.twist : DEFAULTS.twist;
      var canvas = document.createElement('canvas');
      canvas.className = 'vtx-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, t = 0, F = 1, znear = 0.3, parts = [];
      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      function spawn(p, anywhere) {
        p.a = R(0, TAU, 3);
        p.r = R(0.8, 1.2, 2);
        p.z = anywhere ? R(znear, ZFAR, 2) : znear * R(0.6, 1, 2);
        p.v = R(0.8, 1.2, 2);
        p.c = cols[R(0, cols.length - 1)];
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        F = Math.min(w, h) * 0.45;
        // New particles start just outside the corners.
        znear = (F * 1.2) / (Math.hypot(w, h) / 2);
        var n = Math.round(Math.max(160, Math.min(700, (w * h) / 900)) * density);
        while (parts.length < n) { var p = {}; spawn(p, true); parts.push(p); }
        parts.length = n;
        if (still) draw();
      }

      // Where the tunnel's centre is at a given depth: the far end sways
      // more than the near end, so the tunnel seems to bend.
      function centre(z, out) {
        var k = Math.min(1, z / (ZFAR * 0.5));
        out[0] = w / 2 + Math.sin(t * 0.35) * w * 0.08 * k;
        out[1] = h / 2 + Math.cos(t * 0.27) * h * 0.1 * k;
      }

      // Depth speed and spin, so the streak behind each particle is the path
      // it took over the last moment.
      function dz(z) { return (0.35 + z * 0.32) * speed; }
      function da(z) { return twist * (0.35 + 0.13 * z) * speed; }

      var c0 = [0, 0], c1 = [0, 0];
      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.clearRect(0, 0, w, h);
        // A glow at the far end of the tunnel.
        centre(ZFAR, c0);
        var g = ctx.createRadialGradient(c0[0], c0[1], 0, c0[0], c0[1], F * 0.6);
        g.addColorStop(0, 'rgba(' + cols[0] + ',.35)');
        g.addColorStop(1, 'rgba(' + cols[0] + ',0)');
        ctx.fillStyle = g;
        ctx.fillRect(0, 0, w, h);
        ctx.globalCompositeOperation = 'lighter';
        ctx.lineCap = 'round';
        var lag = 0.14;
        for (var i = 0; i < parts.length; i++) {
          var p = parts[i];
          var z1 = Math.max(znear * 0.5, p.z - dz(p.z) * p.v * lag);
          var a1 = p.a - da(p.z) * lag;
          centre(p.z, c0);
          centre(z1, c1);
          var r0 = (p.r * F) / p.z, r1 = (p.r * F) / z1;
          var x0 = c0[0] + Math.cos(p.a) * r0, y0 = c0[1] + Math.sin(p.a) * r0;
          var x1 = c1[0] + Math.cos(a1) * r1, y1 = c1[1] + Math.sin(a1) * r1;
          var alpha = Math.min(1, (ZFAR - p.z) / (ZFAR * 0.55)) * Math.min(1, (p.z / znear) * 0.8);
          if (alpha <= 0.02) continue;
          ctx.strokeStyle = 'rgba(' + p.c + ',' + alpha.toFixed(3) + ')';
          ctx.lineWidth = Math.max(0.5, Math.min(5, (F / 160) * (1.6 / p.z)));
          // A short curve along the spiral.
          ctx.beginPath();
          ctx.moveTo(x1, y1);
          var am = (p.a + a1) / 2, zm = (p.z + z1) / 2, rm = (p.r * F) / zm;
          var cx = (c0[0] + c1[0]) / 2, cy = (c0[1] + c1[1]) / 2;
          ctx.quadraticCurveTo(
            2 * (cx + Math.cos(am) * rm) - (x0 + x1) / 2,
            2 * (cy + Math.sin(am) * rm) - (y0 + y1) / 2,
            x0, y0
          );
          ctx.stroke();
        }
        ctx.globalCompositeOperation = 'source-over';
      }

      function advance(dt) {
        var s = dt / 1000;
        t += s * speed;
        for (var i = 0; i < parts.length; i++) {
          var p = parts[i];
          p.z += dz(p.z) * p.v * s;
          p.a += da(p.z) * s;
          if (p.z > ZFAR) spawn(p);
        }
      }

      t = R(0, 20);
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (still) return;

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

    function init() {
      document.querySelectorAll('[data-vortex-tunnel]').forEach(function (el) {
        if (el._vtx) return;
        el._vtx = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-vortex-tunnel style="background: #07040f; color: #fff">
  <h1>Into the vortex</h1>
</section>