Fourier Epicycles
A chain of spinning circles, each riding on the rim of the one before, draws a heart, a star, a lightning bolt and an infinity sign in turn. The circles are the shape’s Fourier series: the big slow ones give the rough outline and the small fast ones add the detail. Fewer circles give a rounder, wobblier drawing.
How to use it
- Paste the snippet just before
</head>. - Add
data-fourier-epicyclesto a box with a height and a dark background. - Pick shapes with
data-shape, or give your own outline as an SVG path withdata-path.
Options
Add any of these attributes to the same element.
data-shape | heart, star, bolt or infinity, or a comma-separated list to take turns. Default all four. |
data-path | An SVG path’s d to draw instead, in any units: it’s fitted to the box. |
data-terms | How many circles. Default 50. |
data-duration | How long each drawing takes. Default 9000 ms. |
data-color | Colour of the line it draws. Default #f472b6. |
data-circles="false" | Hide the circles and show only the pen. |
The outline is sampled at 256 points and run through a discrete Fourier transform; each frequency becomes one circle, spinning at that frequency, with the biggest first. It’s paused when off screen, and with reduced motion it shows the finished drawing.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Fourier Epicycles: a chain of spinning circles, each one riding on the
// rim of the one before, traces out a shape. The circles are the shape's
// Fourier series: the biggest, slowest ones give the rough outline and
// the small fast ones add the detail.
// Usage: <div data-fourier-epicycles style="height:360px;background:#120a24"></div>
// Optional: data-shape (heart, star, bolt or infinity, or a comma-separated
// list to take turns; default "heart,star,bolt,infinity"), data-path (an SVG
// path "d" string to draw instead, in any units), data-terms (how many
// circles, default 50), data-duration (ms per drawing, default 9000),
// data-color (the line it draws, default #f472b6), data-circles="false"
// (hide the circles and show only the pen).
var DEFAULTS = { shape: 'heart,star,bolt,infinity', terms: 50, duration: 9000, color: '#f472b6' };
var N = 256; // points sampled round the shape
var M = 720; // points along the redrawn curve
var css =
'[data-fourier-epicycles]{position:relative;isolation:isolate;overflow:hidden}' +
'.fep-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; }
// Built-in shapes, as closed lists of points (any scale; they're fitted later).
function polygon(pts) { return pts; }
var SHAPES = {
heart: function () {
var out = [];
for (var i = 0; i < 400; i++) {
var t = i / 400 * Math.PI * 2, s = Math.sin(t);
out.push([16 * s * s * s, -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t))]);
}
return out;
},
star: function () {
var out = [];
for (var i = 0; i < 10; i++) {
var a = -Math.PI / 2 + i * Math.PI / 5, r = i % 2 ? 0.42 : 1;
out.push([Math.cos(a) * r, Math.sin(a) * r]);
}
return polygon(out);
},
bolt: function () {
return polygon([[0.15, -1], [-0.55, 0.12], [-0.05, 0.12], [-0.25, 1], [0.55, -0.22], [0.05, -0.22], [0.35, -1]]);
},
infinity: function () {
var out = [];
for (var i = 0; i < 400; i++) {
var t = i / 400 * Math.PI * 2, d = 1 + Math.sin(t) * Math.sin(t);
out.push([Math.cos(t) / d, Math.sin(t) * Math.cos(t) / d]);
}
return out;
}
};
// Walk round a closed outline and pick n points evenly spaced along it.
function resample(pts, n) {
var lens = [0], total = 0, i;
for (i = 0; i < pts.length; i++) {
var a = pts[i], b = pts[(i + 1) % pts.length];
total += Math.hypot(b[0] - a[0], b[1] - a[1]);
lens.push(total);
}
var out = [], j = 0;
for (i = 0; i < n; i++) {
var target = i / n * total;
while (lens[j + 1] < target) j++;
var p = pts[j], q = pts[(j + 1) % pts.length], seg = lens[j + 1] - lens[j] || 1;
var f = (target - lens[j]) / seg;
out.push([p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f]);
}
return out;
}
function fromPath(el, d) {
var ns = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(ns, 'svg');
svg.setAttribute('aria-hidden', 'true');
svg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
var path = document.createElementNS(ns, 'path');
path.setAttribute('d', d);
svg.appendChild(path);
el.appendChild(svg);
var len = path.getTotalLength(), out = [];
for (var i = 0; i < N; i++) {
var p = path.getPointAtLength(i / N * len);
out.push([p.x, p.y]);
}
el.removeChild(svg);
return out;
}
// Discrete Fourier transform: one spinning circle per frequency, biggest first.
function dft(pts) {
var terms = [];
for (var k = -N / 2; k < N / 2; k++) {
var re = 0, im = 0;
for (var n = 0; n < N; n++) {
var a = -2 * Math.PI * k * n / N;
re += pts[n][0] * Math.cos(a) - pts[n][1] * Math.sin(a);
im += pts[n][0] * Math.sin(a) + pts[n][1] * Math.cos(a);
}
re /= N; im /= N;
terms.push({ k: k, amp: Math.hypot(re, im), phase: Math.atan2(im, re) });
}
terms.sort(function (a, b) { return b.amp - a.amp; });
return terms;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'fep-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var nTerms = Math.max(2, Math.min(N, +d.terms || DEFAULTS.terms));
var duration = +d.duration || DEFAULTS.duration;
var color = d.color || DEFAULTS.color;
var showCircles = d.circles !== 'false';
var names = (d.shape || DEFAULTS.shape).split(',').map(function (s) { return s.trim(); }).filter(function (s) { return SHAPES[s]; });
if (!names.length) names = ['heart'];
// Each shape: unit-sized samples, centred, ready to be scaled to the box.
var raw = d.path ? [fromPath(el, d.path)] : names.map(function (nm) { return resample(SHAPES[nm](), N); });
var shapes = raw.map(function (pts) {
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
pts.forEach(function (p) { minX = Math.min(minX, p[0]); maxX = Math.max(maxX, p[0]); minY = Math.min(minY, p[1]); maxY = Math.max(maxY, p[1]); });
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, sc = 1 / Math.max(maxX - minX, maxY - minY, 1e-6);
var unit = pts.map(function (p) { return [(p[0] - cx) * sc, (p[1] - cy) * sc]; });
return { terms: dft(unit).slice(0, nTerms), aspect: (maxX - minX) / Math.max(1e-6, maxY - minY) };
});
var w = 0, h = 0, dpr = 1, scale = 1, idx = 0, curve = null;
// Where the pen is at time t (0..1), in unit coordinates, plus the circle centres on the way.
function chain(terms, t, centres) {
var x = 0, y = 0;
for (var i = 0; i < terms.length; i++) {
var c = terms[i], a = 2 * Math.PI * c.k * t + c.phase;
if (centres) centres.push(x, y, c.amp);
x += c.amp * Math.cos(a);
y += c.amp * Math.sin(a);
}
return [x, y];
}
function prepare() {
var s = shapes[idx];
// Fit the shape's box to the canvas, leaving room round the edge.
var bw = s.aspect >= 1 ? 1 : s.aspect, bh = s.aspect >= 1 ? 1 / s.aspect : 1;
scale = Math.min(w * 0.78 / bw, h * 0.74 / bh);
curve = [];
for (var i = 0; i <= M; i++) curve.push(chain(s.terms, i / M));
}
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);
prepare();
return true;
}
// t: how far round (0..1); fade: how visible the line is (for the change-over).
function draw(t, fade, circleAlpha) {
ctx.clearRect(0, 0, w, h);
var ox = w / 2, oy = h / 2;
var upto = Math.min(M, Math.floor(t * M));
if (upto > 0) {
ctx.save();
ctx.globalAlpha = fade;
ctx.lineJoin = ctx.lineCap = 'round';
ctx.strokeStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 10;
ctx.lineWidth = Math.max(2, Math.min(w, h) / 140);
ctx.beginPath();
ctx.moveTo(ox + curve[0][0] * scale, oy + curve[0][1] * scale);
for (var i = 1; i <= upto; i++) ctx.lineTo(ox + curve[i][0] * scale, oy + curve[i][1] * scale);
ctx.stroke();
ctx.restore();
}
var centres = [];
var tip = chain(shapes[idx].terms, t % 1, centres);
var px = ox + tip[0] * scale, py = oy + tip[1] * scale;
if (showCircles && circleAlpha > 0) {
ctx.lineWidth = 1;
for (var j = 0; j < centres.length; j += 3) {
var r = centres[j + 2] * scale;
if (r < 0.6) break;
var cx = ox + centres[j] * scale, cy = oy + centres[j + 1] * scale;
var nx = j + 3 < centres.length ? ox + centres[j + 3] * scale : px;
var ny = j + 3 < centres.length ? oy + centres[j + 4] * scale : py;
var fadeBy = circleAlpha * (0.55 - 0.4 * j / centres.length);
ctx.strokeStyle = 'rgba(216,200,255,' + (fadeBy * 0.55).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,' + fadeBy.toFixed(3) + ')';
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(nx, ny);
ctx.stroke();
}
}
ctx.fillStyle = '#fff';
ctx.shadowColor = color;
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
resize();
if (reduced()) {
draw(1, 1, 1);
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(1, 1, 1); }).observe(el);
return;
}
var HOLD = 1400, FADE = 700;
var clock = 0, last = 0;
if (window.ResizeObserver) new ResizeObserver(function () { resize(); }).observe(el);
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
clock += last ? Math.min(100, now - last) : 16;
last = now;
var many = shapes.length > 1;
if (clock < duration) {
draw(clock / duration, 1, 1);
} else if (!many) {
// One shape: keep the finished drawing and let the circles keep spinning over it.
draw(1 + ((clock - duration) / duration) % 1, 1, 1);
} else if (clock < duration + HOLD) {
draw(1, 1, 1);
} else if (clock < duration + HOLD + FADE) {
var f = 1 - (clock - duration - HOLD) / FADE;
draw(1, f, f);
} else {
idx = (idx + 1) % shapes.length;
prepare();
clock = 0;
draw(0, 1, 1);
}
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-fourier-epicycles]').forEach(function (el) {
if (el._fep) return;
el._fep = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-fourier-epicycles style="height: 360px; background: #120a24"></div>
<!-- your own outline, with fewer circles: -->
<div data-fourier-epicycles data-terms="20"
data-path="M10 80 Q 52.5 10, 95 80 T 180 80 L 180 120 L 10 120 Z"
style="height: 360px; background: #120a24"></div>