Cyclist Loop
A cyclist pedals steadily along while the road, grass and clouds slide past. The wheels spin, the cranks turn, and both legs follow the pedals round, bending at the knee, with speed lines streaking by behind.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-cyclist-loop>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-color | Colour of the bike. Default #7c3aed. |
data-jersey | Colour of the rider’s top. Default #f59e0b. |
data-speed | Speed multiplier. Default 1. |
It is 8:5 on a transparent background by default. The road fades out at both ends. It pauses when off screen. With reduced motion it’s a still picture.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Cyclist Loop: a cyclist pedals along on the spot while the road, grass
// and clouds slide past. The wheels spin, the cranks turn and both legs
// follow the pedals, bending at the knee. Drawn as SVG that fills the
// element, and paused when off screen.
// Usage: <div data-cyclist-loop></div>
// It is 8:5 by default on a transparent background.
// Optional: data-color (colour of the bike, default #7c3aed),
// data-jersey (colour of the rider's top, default #f59e0b),
// data-speed (default 1).
var DEFAULTS = { color: '#7c3aed', jersey: '#f59e0b', speed: 1 };
var V = 0.09; // road speed in viewBox units per ms
var uid = 0;
var css =
'[data-cyclist-loop]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
'.cyl-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Bike geometry (viewBox units): wheels, bottom bracket, saddle, bars.
var RW = { x: 148, y: 182 }, FW = { x: 252, y: 182 }, WR = 31;
var BB = { x: 192, y: 186 }, ST = { x: 180, y: 140 }, HT = { x: 238, y: 138 };
var CRANK = 12, THIGH = 38, SHIN = 38;
// Two-bone IK: the joint between a and b, on the side given by `fwd`.
function knee(a, b, l1, l2, fwd) {
var dx = b.x - a.x, dy = b.y - a.y, dd = Math.sqrt(dx * dx + dy * dy);
dd = Math.min(dd, l1 + l2 - 0.01);
var k = (l1 * l1 - l2 * l2 + dd * dd) / (2 * dd);
var h = Math.sqrt(Math.max(0, l1 * l1 - k * k));
var mx = a.x + dx * k / dd, my = a.y + dy * k / dd;
return { x: mx - fwd * dy * h / dd, y: my + fwd * dx * h / dd };
}
function pt(p) { return p.x.toFixed(1) + ' ' + p.y.toFixed(1); }
function wheel(c, cls) {
var s = '<g transform="translate(' + c.x + ' ' + c.y + ')"><circle r="' + WR + '" fill="none" stroke="#1f2937" stroke-width="4.5"/>' +
'<circle r="' + (WR - 3) + '" fill="none" stroke="#94a3b8" stroke-width="1"/><g class="' + cls + '" stroke="#cbd5e1" stroke-width=".8">';
for (var i = 0; i < 12; i++) s += '<path transform="rotate(' + i * 30 + ')" d="M0 0L0 -' + (WR - 3) + '"/>';
return s + '</g><circle r="3.4" fill="#64748b"/></g>';
}
function tile(p, fn) {
var s = '';
for (var x = -p; x <= 400 + p; x += p) s += fn(x);
return s;
}
function build(el) {
var d = el.dataset;
var n = ++uid;
var bike = d.color || DEFAULTS.color;
var jersey = d.jersey || DEFAULTS.jersey;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A cyclist pedalling along');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var g = 'cyl' + n;
var skin = '#f2c29b', shorts = '#1e1b2e';
el.innerHTML =
'<svg class="cyl-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<defs><linearGradient id="' + g + '-fade" gradientUnits="userSpaceOnUse" x1="10" y1="0" x2="390" y2="0">' +
'<stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".15" stop-color="#fff"/><stop offset=".85" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
'<mask id="' + g + '-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="400" height="250"><rect x="10" y="0" width="380" height="250" fill="url(#' + g + '-fade)"/></mask></defs>' +
'<g mask="url(#' + g + '-mask)">' +
'<g class="cyl-layer">' + tile(210, function (x) {
return '<g fill="#e2e8f0"><rect x="' + (x + 20) + '" y="40" width="58" height="14" rx="7"/><circle cx="' + (x + 40) + '" cy="40" r="11"/><circle cx="' + (x + 58) + '" cy="37" r="14"/></g>' +
'<g fill="#e2e8f0" opacity=".7"><rect x="' + (x + 130) + '" y="74" width="40" height="10" rx="5"/><circle cx="' + (x + 150) + '" cy="72" r="10"/></g>';
}) + '</g>' +
'<rect x="0" y="213" width="400" height="3" rx="1.5" fill="#94a3b8"/>' +
'<g class="cyl-layer">' + tile(36, function (x) { return '<rect x="' + x + '" y="226" width="16" height="2.4" rx="1.2" fill="#cbd5e1"/>'; }) + '</g>' +
'<g class="cyl-layer">' + tile(100, function (x) {
return '<path d="M' + (x + 30) + ' 213q2 -8 4 0q2 -10 4 0q2 -7 3 0Z" fill="#86c98a"/><circle cx="' + (x + 78) + '" cy="219" r="1.6" fill="#cbd5e1"/>';
}) + '</g>' +
'</g>' +
'<g class="cyl-lines" stroke="#cbd5e1" stroke-width="2.4" stroke-linecap="round"><path/><path/><path/></g>' +
'<ellipse cx="200" cy="214" rx="72" ry="4" fill="#1e1b2e" opacity=".1"/>' +
'<g class="cyl-rider">' +
// Far side: leg and crank behind the frame.
'<path class="cyl-leg2" fill="none" stroke="#2d2a40" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>' +
'<path class="cyl-shin2" fill="none" stroke="#d9a982" stroke-width="7" stroke-linecap="round"/>' +
'<path class="cyl-shoe2" fill="none" stroke="#334155" stroke-width="6" stroke-linecap="round"/>' +
'<path class="cyl-crank2" stroke="#475569" stroke-width="3.5" stroke-linecap="round"/>' +
'<path class="cyl-arm2" fill="none" stroke="#d9a982" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>' +
wheel(RW, 'cyl-spin') + wheel(FW, 'cyl-spin') +
// Frame.
'<path d="M' + pt(RW) + 'L' + pt(BB) + 'L' + pt(ST) + 'Z M' + pt(BB) + 'L' + pt(HT) + 'L' + pt(ST) + ' M' + pt(HT) + 'L' + pt(FW) + '" fill="none" stroke="' + bike + '" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>' +
'<path d="M238 138L236 128Q236 124 241 124L248 124Q252 125 251 131" fill="none" stroke="#334155" stroke-width="3.5" stroke-linecap="round"/>' +
'<path d="M178 141L176 133" stroke="#334155" stroke-width="3"/><path d="M166 131Q176 128 188 131L186 134L168 134Z" fill="#1f2937"/>' +
'<circle cx="' + BB.x + '" cy="' + BB.y + '" r="5" fill="#475569"/>' +
// Torso, head and helmet.
'<g class="cyl-top">' +
'<path d="M178 128Q190 104 218 96" fill="none" stroke="' + jersey + '" stroke-width="17" stroke-linecap="round"/>' +
'<path d="M182 118L205 104" stroke="#fff" stroke-opacity=".4" stroke-width="3" stroke-linecap="round"/>' +
'<circle cx="229" cy="84" r="10.5" fill="' + skin + '"/>' +
'<path d="M216 82Q218 66 234 68Q244 71 243 80L240 80Z" fill="#fff" stroke="#cbd5e1" stroke-width="1"/>' +
'<path d="M222 72Q232 67 240 74" stroke="' + bike + '" stroke-width="3" fill="none" stroke-linecap="round"/>' +
'<circle cx="235" cy="85" r="1.4" fill="#1e1b2e"/>' +
'</g>' +
// Near side: leg, crank and arm in front of the frame.
'<path class="cyl-leg1" fill="none" stroke="' + shorts + '" stroke-width="10" stroke-linecap="round"/>' +
'<path class="cyl-shin1" fill="none" stroke="' + skin + '" stroke-width="7.5" stroke-linecap="round"/>' +
'<path class="cyl-shoe1" fill="none" stroke="#1f2937" stroke-width="6.5" stroke-linecap="round"/>' +
'<path class="cyl-crank1" stroke="#64748b" stroke-width="4" stroke-linecap="round"/>' +
'<circle cx="' + BB.x + '" cy="' + BB.y + '" r="7" fill="none" stroke="#64748b" stroke-width="2"/>' +
'<path class="cyl-arm1" fill="none" stroke="' + skin + '" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>' +
'<path class="cyl-sleeve" fill="none" stroke="' + jersey + '" stroke-width="9" stroke-linecap="round"/>' +
'</g></svg>';
var svg = el.firstChild;
var q = function (c) { return svg.querySelector('.' + c); };
var layers = svg.querySelectorAll('.cyl-layer');
var periods = [210, 36, 100], factors = [0.12, 1, 1];
var spins = svg.querySelectorAll('.cyl-spin');
var rider = q('cyl-rider'), top = q('cyl-top');
var legs = [
{ thigh: q('cyl-leg1'), shin: q('cyl-shin1'), shoe: q('cyl-shoe1'), crank: q('cyl-crank1'), off: 0 },
{ thigh: q('cyl-leg2'), shin: q('cyl-shin2'), shoe: q('cyl-shoe2'), crank: q('cyl-crank2'), off: Math.PI }
];
var arm1 = q('cyl-arm1'), arm2 = q('cyl-arm2'), sleeve = q('cyl-sleeve');
var lines = svg.querySelectorAll('.cyl-lines path');
function frame(t) {
var dist = t * V;
for (var i = 0; i < layers.length; i++) layers[i].setAttribute('transform', 'translate(' + (-(dist * factors[i]) % periods[i]).toFixed(2) + ' 0)');
var wa = dist / WR;
for (var j = 0; j < spins.length; j++) spins[j].setAttribute('transform', 'rotate(' + (wa * 180 / Math.PI % 360).toFixed(1) + ')');
var ca = wa * 0.55;
// The rider rocks a touch with each pedal stroke.
var rock = Math.sin(ca * 2);
var hip = { x: 180, y: 128 + rock * 0.8 };
top.setAttribute('transform', 'translate(0 ' + (rock * 0.8).toFixed(2) + ')');
legs.forEach(function (l) {
var a = ca + l.off;
var p = { x: BB.x + Math.cos(a) * CRANK, y: BB.y + Math.sin(a) * CRANK };
var k = knee(hip, p, THIGH, SHIN, -1);
l.thigh.setAttribute('d', 'M' + pt(hip) + 'L' + pt(k));
l.shin.setAttribute('d', 'M' + pt(k) + 'L' + pt(p));
// The foot stays roughly level, toe dipping on the down stroke.
var fa = 0.15 + 0.25 * Math.sin(a);
l.shoe.setAttribute('d', 'M' + (p.x - 4 * Math.cos(fa)).toFixed(1) + ' ' + (p.y - 4 * Math.sin(fa)).toFixed(1) + 'L' + (p.x + 9 * Math.cos(fa)).toFixed(1) + ' ' + (p.y + 9 * Math.sin(fa)).toFixed(1));
l.crank.setAttribute('d', 'M' + pt(BB) + 'L' + pt(p));
});
var sh = { x: 214, y: 99 + rock * 0.8 }, hand = { x: 244, y: 125 };
var el1 = knee(sh, hand, 21, 21, 1);
arm1.setAttribute('d', 'M' + pt(sh) + 'L' + pt(el1) + 'L' + pt(hand));
arm2.setAttribute('d', 'M' + (sh.x - 3).toFixed(1) + ' ' + (sh.y + 1).toFixed(1) + 'L' + (el1.x - 3).toFixed(1) + ' ' + (el1.y).toFixed(1) + 'L' + (hand.x - 2) + ' ' + (hand.y + 1));
sleeve.setAttribute('d', 'M' + pt(sh) + 'L' + (sh.x + (el1.x - sh.x) * 0.4).toFixed(1) + ' ' + (sh.y + (el1.y - sh.y) * 0.4).toFixed(1));
rider.setAttribute('transform', 'translate(0 ' + (Math.abs(Math.sin(dist * 0.05)) * -0.6).toFixed(2) + ')');
// Speed lines streak past behind the rider.
for (var s = 0; s < lines.length; s++) {
var ph = (t / 900 + s / lines.length) % 1;
var x = 120 - ph * 60, y = 110 + s * 26;
lines[s].setAttribute('d', 'M' + x.toFixed(1) + ' ' + y + 'L' + (x - 18).toFixed(1) + ' ' + y);
lines[s].setAttribute('opacity', (Math.sin(ph * Math.PI) * 0.9).toFixed(2));
}
}
if (reduced()) { frame(400); return; }
var t = 0, last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += (last ? Math.min(50, now - last) : 16) * speed;
last = now;
frame(t);
}
});
frame(0);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-cyclist-loop]').forEach(function (el) {
if (el._cyl) return; // idempotent
el._cyl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-cyclist-loop></div>
<h2>On our way</h2>
<!-- a faster ride on a red bike: -->
<div data-cyclist-loop data-speed="1.6" data-color="#ef4444"></div>