Lorenz Attractor
Three glowing trails loop round the butterfly-shaped Lorenz attractor, circling one wing a few times, then flipping to the other with no pattern you can predict. They start almost together and soon go their own ways, while the whole shape turns slowly in 3D.
How to use it
- Paste the snippet just before
</head>. - Add
data-lorenz-attractorto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-trails | How many trails, 1 to 8. Default 3. |
data-colors | Comma-separated colours, one per trail. Default pink, violet and sky. |
data-length | Points in each trail: longer trails fill in more of the wings. Default 1400. |
data-speed | Speed multiplier. Default 1. |
data-spin | Turns per minute, or 0 to hold still. Default 2. |
Edward Lorenz found it in 1963 in a toy weather model: three simple equations whose paths never repeat and never settle, and one of the first pictures of chaos. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Lorenz Attractor: glowing trails loop round the butterfly-shaped Lorenz
// attractor, swapping wings at random, while the whole thing turns slowly
// in 3D. Each trail fades out along its tail. Drawn on a transparent
// canvas and paused when off screen.
// Usage: <div data-lorenz-attractor style="height:360px"></div>
// Optional: data-trails (how many trails, 1 to 8, default 3), data-colors
// (comma-separated, one per trail, default pink, violet and sky),
// data-length (points in each trail, default 1400), data-speed (speed
// multiplier, default 1), data-spin (turns per minute, default 2, 0 to
// hold still).
var DEFAULTS = { trails: 3, colors: '#f472b6,#a78bfa,#38bdf8,#fbbf24,#34d399', length: 1400, speed: 1, spin: 2 };
// The classic constants, and the time step of each integration step.
var SIGMA = 10, RHO = 28, BETA = 8 / 3, DT = 0.0055;
var CHUNK = 24;
var css =
'[data-lorenz-attractor]{position:relative;isolation:isolate;overflow:hidden}' +
'.lrz-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; }
// One small step along the flow (midpoint method: smooth and cheap).
function step(p) {
var x = p[0], y = p[1], z = p[2];
var mx = x + SIGMA * (y - x) * DT / 2;
var my = y + (x * (RHO - z) - y) * DT / 2;
var mz = z + (x * y - BETA * z) * DT / 2;
p[0] = x + SIGMA * (my - mx) * DT;
p[1] = y + (mx * (RHO - mz) - my) * DT;
p[2] = z + (mx * my - BETA * mz) * DT;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'lrz-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var count = Math.max(1, Math.min(8, +d.trails || DEFAULTS.trails));
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
while (colors.length < count) colors = colors.concat(colors);
var len = Math.max(200, +d.length || DEFAULTS.length);
var speed = d.speed !== undefined ? +d.speed || 0 : DEFAULTS.speed;
var spin = d.spin !== undefined ? +d.spin || 0 : DEFAULTS.spin;
var w = 0, h = 0, dpr = 1, scale = 1, angle = 0.6, carry = 0;
// Each trail: a ring buffer of 3D points, and how many are filled.
var trails = [];
for (var i = 0; i < count; i++) {
// Start close together: the trails soon go their own ways.
var p = [1 + i * 0.6, 1 + i * 0.3, 20 + i];
for (var k = 0; k < 400; k++) step(p);
trails.push({ p: p, buf: new Float32Array(len * 3), head: 0, n: 0, color: colors[i] });
}
function advance(t) {
step(t.p);
t.buf[t.head * 3] = t.p[0];
t.buf[t.head * 3 + 1] = t.p[1];
t.buf[t.head * 3 + 2] = t.p[2];
t.head = (t.head + 1) % len;
if (t.n < len) t.n++;
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return false;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// The attractor is about 50 wide (as it turns) and 50 tall.
scale = Math.min(w / 54, h / 52);
return true;
}
// Screen position of the i-th point (0 = oldest) of a trail.
function project(t, i, out) {
var j = ((t.head - t.n + i) % len + len) % len;
var x = t.buf[j * 3], y = t.buf[j * 3 + 1], z = t.buf[j * 3 + 2];
var c = Math.cos(angle), s = Math.sin(angle);
var rx = x * c - y * s, depth = x * s + y * c;
// A touch of perspective, so the near wing looks nearer.
var f = 1 + depth / 160;
out[0] = w / 2 + rx * scale * f;
out[1] = h / 2 - (z - 25) * scale * f;
return out;
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = ctx.lineJoin = 'round';
var a = [0, 0], lw = Math.max(1, Math.min(2.2, scale * 0.16));
trails.forEach(function (t) {
if (t.n < 2) return;
ctx.strokeStyle = t.color;
// Oldest to newest in chunks, each a little brighter than the last.
for (var s0 = 0; s0 < t.n - 1; s0 += CHUNK) {
var s1 = Math.min(t.n - 1, s0 + CHUNK), k = s1 / t.n;
ctx.globalAlpha = 0.05 + 0.85 * k * k;
ctx.lineWidth = lw * (0.6 + 0.6 * k);
ctx.beginPath();
project(t, s0, a);
ctx.moveTo(a[0], a[1]);
for (var i = s0 + 1; i <= s1; i++) {
project(t, i, a);
ctx.lineTo(a[0], a[1]);
}
ctx.stroke();
}
// A glowing head.
project(t, t.n - 1, a);
var g = ctx.createRadialGradient(a[0], a[1], 0, a[0], a[1], 14);
g.addColorStop(0, t.color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = 0.9;
ctx.fillStyle = g;
ctx.fillRect(a[0] - 14, a[1] - 14, 28, 28);
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(a[0], a[1], 2, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
resize();
// Begin with trails already a good way round, so it reads at once.
trails.forEach(function (t) { for (var k = 0; k < len * 0.6; k++) advance(t); });
if (reduced()) {
trails.forEach(function (t) { while (t.n < len) advance(t); });
draw();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).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;
// About 190 steps a second at speed 1.
carry += dt * 0.19 * speed;
var n = Math.floor(carry);
carry -= n;
for (var k = 0; k < n; k++) trails.forEach(advance);
angle += dt / 60000 * spin * Math.PI * 2;
draw();
}
});
// 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-lorenz-attractor]').forEach(function (el) {
if (el._lrz) return;
el._lrz = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-lorenz-attractor style="height: 360px; background: #0b0618"></div>