Lissajous Curves
One oscillation swings a pen from side to side, another swings it up and down at a different rate, and together they trace a Lissajous figure. The timing between them drifts, so the figure seems to turn in 3D, and every few seconds the rates change and it morphs into a new one.
How to use it
- Paste the snippet just before
</head>. - Add
data-lissajous-curvesto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-ratios | Comma-separated side : up rates to take turns with. Default 3:2,5:4,3:4,1:2,5:6,2:3. |
data-change | Time on each figure. Default 5000 ms. |
data-colors | Two colours across the figure. Default #f472b6,#a78bfa. |
data-speed | Speed multiplier. Default 1. |
data-guides="false" | Hide the two oscillation dots on the edges. |
The dot along the bottom shows the side-to-side swing and the dot up the left shows the up-and-down one. Jules Antoine Lissajous made these figures in 1857 with mirrors on two tuning forks. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows a still figure.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Lissajous Curves: one oscillation swings a pen side to side, another
// swings it up and down at a different rate, and the pen traces a
// Lissajous figure. The phase between them drifts, so the figure seems to
// turn in 3D, and every few seconds the rates change and it morphs into a
// new figure. Two little dots on the edges show the two oscillations.
// Drawn on a transparent canvas and paused when off screen.
// Usage: <div data-lissajous-curves style="height:360px"></div>
// Optional: data-ratios (comma-separated side:up rates to take turns
// with, default "3:2,5:4,3:4,1:2,5:6,2:3"), data-colors (two colours
// across the figure, default "#f472b6,#a78bfa"), data-speed (speed
// multiplier, default 1), data-change (time on each figure, ms, default
// 5000), data-guides="false" (hide the oscillation dots).
var DEFAULTS = { ratios: '3:2,5:4,3:4,1:2,5:6,2:3', colors: '#f472b6,#a78bfa', speed: 1, change: 5000, morph: 1400 };
var STEPS = 900;
var css =
'[data-lissajous-curves]{position:relative;isolation:isolate;overflow:hidden}' +
'.lsj-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 ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'lsj-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var ratios = (d.ratios || DEFAULTS.ratios).split(',').map(function (s) {
var q = s.split(':');
return [+q[0] || 1, +q[1] || 1];
});
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
if (colors.length < 2) colors.push(colors[0]);
var speed = +d.speed || DEFAULTS.speed;
var change = +d.change || DEFAULTS.change;
var guides = d.guides !== 'false';
var w = 0, h = 0, dpr = 1, cx = 0, cy = 0, ax = 1, ay = 1, gx = 0, gy = 0, grad = null;
// Rates now (fa, fb), the figure we're on, the phase and the pen's position.
var cur = 0, fa = ratios[0][0], fb = ratios[0][1], from = null, mt = 1;
var delta = Math.PI / 4, tau = 0, age = 0;
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);
// Leave room on the left and at the bottom for the oscillation dots.
var g = guides ? Math.max(14, Math.min(26, Math.min(w, h) * 0.08)) : 0;
var pad = Math.max(12, Math.min(w, h) * 0.07);
var bw = w - g - pad * 2, bh = h - g - pad * 2;
ay = bh / 2;
ax = Math.min(bw / 2, ay * 1.5);
cx = g + pad + bw / 2;
cy = pad + bh / 2;
// The guide tracks sit just outside the figure.
gx = cx - ax - (g ? g / 2 + 6 : 0);
gy = cy + ay + (g ? g / 2 + 6 : 0);
grad = ctx.createLinearGradient(cx - ax, cy - ay, cx + ax, cy + ay);
grad.addColorStop(0, colors[0]);
grad.addColorStop(1, colors[1]);
return true;
}
function px(t) { return cx + ax * Math.sin(fa * t + delta); }
function py(t) { return cy - ay * Math.sin(fb * t); }
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.lineCap = ctx.lineJoin = 'round';
// The whole figure, faint.
ctx.strokeStyle = grad;
ctx.globalAlpha = 0.38;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (var i = 0; i <= STEPS; i++) {
var t = i / STEPS * Math.PI * 2;
if (i) ctx.lineTo(px(t), py(t)); else ctx.moveTo(px(t), py(t));
}
ctx.stroke();
// A bright tail behind the pen.
ctx.lineCap = 'butt';
var tail = 5 / Math.max(fa, fb), parts = 40;
for (var k = 0; k < parts; k++) {
var t0 = tau - tail * (1 - k / parts), t1 = tau - tail * (1 - (k + 1) / parts), f = (k + 1) / parts;
ctx.globalAlpha = f * f;
ctx.lineWidth = 1.5 + f * 1.8;
ctx.beginPath();
ctx.moveTo(px(t0), py(t0));
for (var s = 1; s <= 4; s++) {
var tt = t0 + (t1 - t0) * s / 4;
ctx.lineTo(px(tt), py(tt));
}
ctx.stroke();
}
var x = px(tau), y = py(tau);
if (guides) {
// The two oscillations, and dashed lines from them to the pen.
ctx.globalAlpha = 0.35;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.setLineDash([2, 4]);
ctx.beginPath();
ctx.moveTo(x, gy); ctx.lineTo(x, y);
ctx.moveTo(gx, y); ctx.lineTo(x, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 0.18;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx - ax, gy); ctx.lineTo(cx + ax, gy);
ctx.moveTo(gx, cy - ay); ctx.lineTo(gx, cy + ay);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.fillStyle = colors[0];
ctx.beginPath();
ctx.arc(x, gy, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = colors[1];
ctx.beginPath();
ctx.arc(gx, y, 4, 0, Math.PI * 2);
ctx.fill();
}
// The pen.
ctx.globalAlpha = 1;
var g = ctx.createRadialGradient(x, y, 0, x, y, 16);
g.addColorStop(0, 'rgba(255,255,255,.9)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(x - 16, y - 16, 32, 32);
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(x, y, 2.6, 0, Math.PI * 2);
ctx.fill();
}
function tick(dt) {
age += dt;
// The pen goes round once every few seconds; the phase drifts slowly.
tau = (tau + dt / 1000 * 0.9 * speed / Math.max(1, Math.max(fa, fb) / 3)) % (Math.PI * 2 * 1000);
delta += dt / 1000 * 0.35 * speed;
if (ratios.length > 1 && age > change) {
age = 0;
from = [fa, fb];
cur = (cur + 1) % ratios.length;
mt = 0;
}
if (mt < 1 && from) {
mt = Math.min(1, mt + dt / DEFAULTS.morph);
var e = ease(mt), to = ratios[cur];
fa = from[0] + (to[0] - from[0]) * e;
fb = from[1] + (to[1] - from[1]) * e;
}
}
resize();
if (reduced()) {
tau = 1;
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;
tick(dt);
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-lissajous-curves]').forEach(function (el) {
if (el._lsj) return;
el._lsj = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-lissajous-curves style="height: 360px; background: #0f0a1e"></div>