Pendulum Wave
A row of pendulums, each a little shorter than the last, is let go together. Each swings at its own pace, so the row drifts out of step into travelling waves, splits into two, three and four groups, falls into chaos, and then comes right back into line. Seen from above and in front, so a bob swinging towards you drops lower and grows a little.
How to use it
- Paste the snippet just before
</head>. - Add
data-pendulum-waveto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-count | How many pendulums. Default 15. |
data-cycle | Seconds until they all line up again. Default 30. |
data-swings | How many swings the longest makes in one cycle; each next one makes one more. Default 20. |
data-colors | Comma-separated colours along the row. Default #818cf8,#c084fc,#f472b6,#fb923c. |
data-line="false" | Hide the faint line joining the bobs. |
Pendulum n makes exactly swings + n swings per cycle, and a pendulum’s length goes as one over the square of how fast it swings, which sets how low each one hangs. It’s paused when off screen, and with reduced motion it shows a still a third of the way through, in three neat groups.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pendulum Wave: a row of pendulums, each a little shorter than the last,
// let go together. Each swings at its own pace, so the row drifts out of
// step into travelling waves, splits into two, three or four groups, falls
// into chaos, and then comes right back into line, all swinging as one.
// Seen from above and in front, so a bob swinging towards you drops lower.
// Usage: <div data-pendulum-wave style="height:360px;background:#120a24"></div>
// Optional: data-count (how many pendulums, default 15), data-cycle (seconds
// until they all line up again, default 30), data-swings (how many swings
// the longest pendulum makes in one cycle, default 20), data-colors
// (comma-separated colours across the row, default violet to pink to
// amber), data-line="false" (hide the faint line joining the bobs).
var DEFAULTS = { count: 15, cycle: 30, swings: 20, colors: '#818cf8,#c084fc,#f472b6,#fb923c' };
var css =
'[data-pendulum-wave]{position:relative;isolation:isolate;overflow:hidden}' +
'.pdw-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 hex(c) {
c = c.replace('#', '');
if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
var n = parseInt(c, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
// A colour partway along a list of colours (t from 0 to 1).
function blend(list, t) {
var f = t * (list.length - 1), i = Math.min(list.length - 2, Math.floor(f)), k = f - i;
if (list.length === 1) return 'rgb(' + list[0].join(',') + ')';
var a = list[i], b = list[i + 1];
return 'rgb(' + Math.round(a[0] + (b[0] - a[0]) * k) + ',' + Math.round(a[1] + (b[1] - a[1]) * k) + ',' + Math.round(a[2] + (b[2] - a[2]) * k) + ')';
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'pdw-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var count = Math.max(3, Math.min(40, Math.round(+d.count || DEFAULTS.count)));
var cycle = +d.cycle || DEFAULTS.cycle;
var swings = Math.max(1, Math.round(+d.swings || DEFAULTS.swings));
var palette = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return hex(c.trim()); });
var showLine = d.line !== 'false';
var w = 0, h = 0, dpr = 1;
var colors = [];
for (var c = 0; c < count; c++) colors.push(blend(palette, count === 1 ? 0 : c / (count - 1)));
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);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return true;
}
// Pendulum i makes (swings + i) full swings per cycle. Its length goes as
// one over the square of that, so the longest hangs lowest.
function draw(t) {
ctx.clearRect(0, 0, w, h);
var pad = Math.max(18, w * 0.07);
var gap = (w - pad * 2) / (count - 1);
var top = h * 0.12, spanY = h * 0.5;
var amp = Math.min(h * 0.17, 60);
var r = Math.max(4, Math.min(gap * 0.36, h * 0.045));
var longest = 1 / (swings * swings);
var bobs = [];
for (var i = 0; i < count; i++) {
var n = swings + i;
var len = (1 / (n * n)) / longest; // 1 for the longest, shorter after
var phase = Math.cos(2 * Math.PI * n * t / cycle);
var x = pad + i * gap;
var restY = top + spanY * (0.35 + 0.65 * len);
var y = restY + phase * amp; // towards the viewer = lower on screen
var depth = 1 + phase * 0.22; // and a little bigger
bobs.push([x, y, depth, restY]);
}
// The bar they hang from.
ctx.strokeStyle = 'rgba(255,255,255,.35)';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(pad - gap * 0.4, top);
ctx.lineTo(w - pad + gap * 0.4, top);
ctx.stroke();
// Strings.
ctx.lineWidth = 1;
for (i = 0; i < count; i++) {
ctx.strokeStyle = 'rgba(255,255,255,.22)';
ctx.beginPath();
ctx.moveTo(bobs[i][0], top);
ctx.lineTo(bobs[i][0], bobs[i][1]);
ctx.stroke();
}
if (showLine) {
ctx.strokeStyle = 'rgba(244,114,182,.28)';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (i = 0; i < count; i++) {
if (i) ctx.lineTo(bobs[i][0], bobs[i][1]);
else ctx.moveTo(bobs[i][0], bobs[i][1]);
}
ctx.stroke();
}
// Bobs, with a soft shadow on the floor of where they hang.
for (i = 0; i < count; i++) {
var b = bobs[i], rr = r * b[2];
ctx.fillStyle = 'rgba(0,0,0,.25)';
ctx.beginPath();
ctx.ellipse(b[0], h * 0.93, r * 1.1, r * 0.3, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = colors[i];
ctx.shadowColor = colors[i];
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.arc(b[0], b[1], rr, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,.55)';
ctx.beginPath();
ctx.arc(b[0] - rr * 0.3, b[1] - rr * 0.35, rr * 0.3, 0, Math.PI * 2);
ctx.fill();
}
}
resize();
if (reduced()) {
// A still a third of the way through: three neat groups.
draw(cycle / 3);
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(cycle / 3); }).observe(el);
return;
}
var clock = 0, last = 0;
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(clock / 1000); }).observe(el);
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
clock = (clock + (last ? Math.min(100, now - last) : 16)) % (cycle * 1000);
last = now;
draw(clock / 1000);
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-pendulum-wave]').forEach(function (el) {
if (el._pdw) return;
el._pdw = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-pendulum-wave style="height: 360px; background: #120a24"></div>