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
- Paste the snippet just before
</head>. - Add
data-strange-attractorto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-shape | Comma-separated 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. Default 6000 ms. |
data-loop | Hold 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.
<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><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>