← Generative art

Strange Attractor

A single point hops round and round by a simple formula, millions of times, and every place it lands glows a little brighter. Out of the haze, silky ribbons and folds pile up. Loop it and it fades and grows a different shape.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-strange-attractor to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-shapeComma-separated shapes to take turns with: silk, wings, knot, petals, ribbon, swirl. Default all of them.
data-colorsThree colours, from faint to bright. Default #4c1d95,#ec4899,#fef3c7.
data-durationHow long each shape takes to build. Default 6000 ms.
data-loopHold the finished shape, fade it and grow the next one.

These are Clifford attractors: the point’s next position is sin(a y) + c cos(a x) across and sin(b x) + d cos(b y) down, and the four numbers pick the shape. Brightness counts landings on a log scale, so faint strands and dense folds both show. It’s paused when off screen, and with reduced motion the finished shape appears at once.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Strange Attractor: one point hops round and round by a simple formula,
    // millions of times, and every place it lands gets a little brighter.
    // Silky ribbon shapes pile up out of the haze. With data-loop it holds the
    // finished shape, fades it and grows the next one. Paused when off screen.
    // Usage: <div data-strange-attractor style="height:360px"></div>
    // Optional: data-shape (a comma-separated list of shapes to take turns
    // with: silk, wings, knot, petals, ribbon, swirl; default all of them),
    // data-colors (three colours from faint to bright, default
    // "#4c1d95,#ec4899,#fef3c7"), data-duration (how long each shape takes to
    // build, ms, default 6000), data-loop (move on to the next shape).
    var DEFAULTS = { colors: '#4c1d95,#ec4899,#fef3c7', duration: 6000, hold: 2500, fade: 900, budget: 7 };
    // Clifford attractors: x' = sin(a y) + c cos(a x), y' = sin(b x) + d cos(b y).
    var SHAPES = {
      silk: [-1.4, 1.6, 1.0, 0.7],
      wings: [1.7, 1.7, 0.6, 1.2],
      knot: [-1.7, 1.3, -0.1, -1.21],
      petals: [-1.8, -2.0, -0.5, -0.9],
      ribbon: [1.5, -1.8, 1.6, 0.9],
      swirl: [-1.24, -1.25, -1.81, -1.91]
    };

    var css =
      '[data-strange-attractor]{position:relative;isolation:isolate;overflow:hidden}' +
      '.sat-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function rgb(hex) {
      var n = parseInt(hex.trim().replace('#', ''), 16);
      return [n >> 16, n >> 8 & 255, n & 255];
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'sat-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var names = (d.shape || Object.keys(SHAPES).join(',')).split(',')
        .map(function (s) { return s.trim(); }).filter(function (s) { return SHAPES[s]; });
      if (!names.length) names = ['silk'];
      var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
      while (cs.length < 3) cs.push(cs[cs.length - 1]);
      var duration = +d.duration || DEFAULTS.duration;
      var loop = d.loop !== undefined && d.loop !== 'false';
      // A 256-step colour ramp: transparent, then faint, middle and bright.
      var ramp = new Uint8ClampedArray(256 * 4);
      for (var r = 0; r < 256; r++) {
        var k = r / 255, c0, c1, f;
        if (k < 0.5) { c0 = cs[0]; c1 = cs[1]; f = k * 2; } else { c0 = cs[1]; c1 = cs[2]; f = (k - 0.5) * 2; }
        ramp[r * 4] = c0[0] + (c1[0] - c0[0]) * f;
        ramp[r * 4 + 1] = c0[1] + (c1[1] - c0[1]) * f;
        ramp[r * 4 + 2] = c0[2] + (c1[2] - c0[2]) * f;
        ramp[r * 4 + 3] = Math.min(255, 40 + r * 4);
      }
      var w = 0, h = 0, res = 1, bw = 0, bh = 0, hist = null, img = null;
      var shape = 0, P = SHAPES[names[0]], px = 0.1, py = 0.1, peak = 1, total = 0, target = 0;
      var bx0 = 0, by0 = 0, sx = 1, sy = 1, phase = 'build', age = 0, frame = 0;

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        // Keep the histogram under about 900,000 pixels.
        res = Math.min(2, window.devicePixelRatio || 1, Math.sqrt(900000 / Math.max(1, w * h)));
        bw = Math.max(1, Math.round(w * res));
        bh = Math.max(1, Math.round(h * res));
        canvas.width = bw;
        canvas.height = bh;
        hist = new Float32Array(bw * bh);
        img = ctx.createImageData(bw, bh);
        // Enough points for a smooth image: a few million on a big box.
        target = Math.max(1500000, Math.min(9000000, bw * bh * 9));
        return true;
      }

      // Start a shape: find its extent, then fit it to the box.
      function begin(name) {
        P = SHAPES[name];
        var x = 0.1, y = 0.1, minx = 9, maxx = -9, miny = 9, maxy = -9;
        for (var i = 0; i < 20000; i++) {
          var nx = Math.sin(P[0] * y) + P[2] * Math.cos(P[0] * x);
          y = Math.sin(P[1] * x) + P[3] * Math.cos(P[1] * y);
          x = nx;
          if (i < 100) continue;
          if (x < minx) minx = x; if (x > maxx) maxx = x;
          if (y < miny) miny = y; if (y > maxy) maxy = y;
        }
        px = x; py = y;
        var pad = 0.08, fw = bw * (1 - pad * 2), fh = bh * (1 - pad * 2);
        var s = Math.min(fw / (maxx - minx), fh / (maxy - miny));
        sx = sy = s;
        bx0 = bw / 2 - (minx + maxx) / 2 * s;
        by0 = bh / 2 - (miny + maxy) / 2 * s;
        hist.fill(0);
        peak = 1;
        total = 0;
        phase = 'build';
        age = 0;
        canvas.style.opacity = 1;
      }

      // Hop n times, adding each landing to the histogram.
      function hop(n) {
        var a = P[0], b = P[1], c = P[2], dd = P[3], x = px, y = py, mx = peak;
        for (var i = 0; i < n; i++) {
          var nx = Math.sin(a * y) + c * Math.cos(a * x);
          y = Math.sin(b * x) + dd * Math.cos(b * y);
          x = nx;
          var ix = (bx0 + x * sx) | 0, iy = (by0 + y * sy) | 0;
          if (ix >= 0 && iy >= 0 && ix < bw && iy < bh) {
            var v = ++hist[iy * bw + ix];
            if (v > mx) mx = v;
          }
        }
        px = x; py = y; peak = mx;
        total += n;
      }

      // Tone-map the histogram (log scale) through the colour ramp.
      function paint() {
        var data = img.data, lp = Math.log(peak + 1), n = bw * bh;
        // Early on, brighten a little so the shape shows straight away.
        var gain = 1 + 0.5 * (1 - Math.min(1, total / target));
        for (var i = 0, o = 0; i < n; i++, o += 4) {
          var v = hist[i];
          if (!v) { data[o + 3] = 0; continue; }
          // A gentle curve keeps the middle tones dim, so the bright folds stand out.
          var t = Math.min(255, (Math.pow(Math.log(v + 1) / lp, 1.6) * gain * 255) | 0) * 4;
          data[o] = ramp[t];
          data[o + 1] = ramp[t + 1];
          data[o + 2] = ramp[t + 2];
          data[o + 3] = ramp[t + 3];
        }
        ctx.putImageData(img, 0, 0);
      }

      function tick(dt) {
        age += dt;
        if (phase === 'build') {
          // Hop as many times as fits a few milliseconds, aiming for the target.
          var want = Math.ceil(target * dt / duration), t0 = performance.now();
          while (want > 0 && performance.now() - t0 < DEFAULTS.budget) {
            var n = Math.min(want, 40000);
            hop(n);
            want -= n;
          }
          if (++frame % 2 === 0 || total >= target) paint();
          if (total >= target) { phase = 'hold'; age = 0; }
        } else if (phase === 'hold' && loop && age > DEFAULTS.hold) {
          phase = 'fade';
          anime.animate(canvas, {
            opacity: 0, duration: DEFAULTS.fade, ease: 'inOut(2)',
            onComplete: function () {
              shape = (shape + 1) % names.length;
              begin(names[shape]);
            }
          });
        }
      }

      resize();
      begin(names[0]);
      if (reduced()) {
        hop(target);
        paint();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { begin(names[shape]); hop(target); paint(); } }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) begin(names[shape]); }).observe(el);

      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;
          tick(dt);
        }
      });
      // Pause while off screen to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-strange-attractor]').forEach(function (el) {
        if (el._sat) return;
        el._sat = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-strange-attractor data-loop style="height: 360px; background: #0a0614"></div>

<!-- just one shape: -->
<div data-strange-attractor data-shape="wings" style="height: 360px; background: #0a0614"></div>