Road Trip Loop
A little car with suitcases on the roof drives along an endless road, wheels spinning and puffing exhaust. Mountains, hills, trees and roadside posts roll past at different speeds, so the scene has real depth.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-road-trip-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 car. Default #7c3aed. |
data-speed | Speed multiplier. Default 1. |
It is 8:5 with a sky background by default; set your own height or background on the element to change that. The road stretches to fill any width. 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 () {
// Road Trip Loop: a little car with luggage on the roof drives along an
// endless road while mountains, hills, trees and roadside posts roll past
// at different speeds. Drawn as SVG that fills the element, and paused
// when off screen.
// Usage: <div data-road-trip-loop></div>
// It is 8:5 by default with a sky background; give it your own height or
// background to change that. The road stretches to fill any width.
// Optional: data-color (colour of the car, default #7c3aed),
// data-speed (default 1).
var DEFAULTS = { color: '#7c3aed', speed: 1 };
var V = 0.11; // road speed in viewBox units per ms
var css =
'[data-road-trip-loop]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
'background:linear-gradient(#7cb8f5,#c7e0fb 55%,#fde9b8)}' +
'.rdt-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; }
// A wavy ridge that repeats every `p` units, as a closed path.
function ridge(p, base, amp, ph, col) {
var w = 2 * Math.PI / p, d = 'M-900 600';
for (var x = -900; x <= 1400 + p; x += 6) {
var y = base - amp * (0.6 * Math.sin(x * w + ph) + 0.3 * Math.sin(x * w * 2 + ph * 2.3) + 0.25 * Math.sin(x * w * 3 + 1));
d += 'L' + x + ' ' + y.toFixed(1);
}
return '<path d="' + d + 'L' + (1400 + p) + ' 600Z" fill="' + col + '"/>';
}
function hillY(x) {
var w = 2 * Math.PI / 260;
return 172 - 9 * (0.6 * Math.sin(x * w + 0.4) + 0.3 * Math.sin(x * w * 2 + 2));
}
// Repeat `fn(x)` every `p` units across the visible range.
function tile(p, fn) {
var s = '';
for (var x = Math.floor(-900 / p) * p; x <= 1400 + p; x += p) s += fn(x);
return s;
}
function tree(x, y, h, pine) {
if (pine) {
return '<rect x="' + (x - 1.5) + '" y="' + (y - 6) + '" width="3" height="7" fill="#7c5434"/>' +
'<path d="M' + x + ' ' + (y - h) + 'L' + (x + h * 0.32) + ' ' + (y - 5) + 'L' + (x - h * 0.32) + ' ' + (y - 5) + 'Z" fill="#2f7d4a"/>';
}
return '<rect x="' + (x - 1.5) + '" y="' + (y - 8) + '" width="3" height="9" fill="#7c5434"/>' +
'<circle cx="' + x + '" cy="' + (y - h * 0.6) + '" r="' + (h * 0.42) + '" fill="#3f9d55"/>' +
'<circle cx="' + (x - h * 0.12) + '" cy="' + (y - h * 0.7) + '" r="' + (h * 0.2) + '" fill="#5cb96f"/>';
}
function cloud(x, y, w) {
return '<g fill="#fff" opacity=".9"><rect x="' + (x - w / 2) + '" y="' + (y - 7) + '" width="' + w + '" height="14" rx="7"/>' +
'<circle cx="' + (x - w * 0.15) + '" cy="' + (y - 8) + '" r="' + (w * 0.2) + '"/><circle cx="' + (x + w * 0.14) + '" cy="' + (y - 11) + '" r="' + (w * 0.25) + '"/></g>';
}
function wheel(cx) {
var s = '<g transform="translate(' + cx + ' -9)"><circle r="9" fill="#1f2937"/><g class="rdt-spin">' +
'<circle r="5.2" fill="#e5e7eb"/>';
for (var i = 0; i < 5; i++) s += '<path transform="rotate(' + i * 72 + ')" d="M0 0L0 -5" stroke="#94a3b8" stroke-width="1.4"/>';
return s + '<circle r="1.6" fill="#64748b"/></g></g>';
}
function build(el) {
var d = el.dataset;
var col = d.color || DEFAULTS.color;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A car driving along an endless road');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var layers = [
// [period, speed factor, content]
[440, 0.04, tile(440, function (x) { return cloud(x + 60, 46, 64) + cloud(x + 260, 30, 48) + cloud(x + 370, 66, 40); })],
[360, 0.12, ridge(360, 136, 26, 0.8, '#a5b4dc') + ridge(180, 150, 12, 2.2, '#93a5d4')],
[260, 0.35, '<path d="' + (function () {
var p = 'M-900 600';
for (var x = -900; x <= 1660; x += 6) p += 'L' + x + ' ' + hillY(x).toFixed(1);
return p + 'L1660 600Z';
})() + '" fill="#86c98a"/>' + tile(260, function (x) {
return tree(x + 22, hillY(x + 22) + 2, 22, true) + tree(x + 40, hillY(x + 40) + 3, 16, true) +
tree(x + 120, hillY(x + 120) + 2, 20, false) + tree(x + 196, hillY(x + 196) + 2, 26, true);
})],
[40, 1, ''],
[150, 1.25, '']
];
var html = '<svg class="rdt-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<circle cx="318" cy="92" r="40" fill="#fff7cc" opacity=".45"/><circle cx="318" cy="92" r="20" fill="#fde68a"/>';
for (var i = 0; i < 3; i++) html += '<g class="rdt-layer">' + layers[i][2] + '</g>';
// The verge and the road.
html += '<rect x="-900" y="178" width="2300" height="500" fill="#6dbb72"/>' +
'<rect x="-900" y="188" width="2300" height="38" fill="#4b5563"/>' +
'<rect x="-900" y="188" width="2300" height="2.5" fill="#d1d5db"/><rect x="-900" y="223.5" width="2300" height="2.5" fill="#d1d5db"/>' +
'<g class="rdt-layer">' + tile(40, function (x) { return '<rect x="' + x + '" y="206" width="20" height="2.4" rx="1" fill="#fef3c7"/>'; }) + '</g>';
// Exhaust puffs and the car.
html += '<g class="rdt-puffs"><circle r="3" fill="#e5e7eb"/><circle r="3" fill="#e5e7eb"/><circle r="3" fill="#e5e7eb"/></g>' +
'<g transform="translate(196 216)"><ellipse cx="0" cy="0" rx="44" ry="3.4" fill="#000" opacity=".18"/><g class="rdt-car">' +
// Luggage on the roof rack.
'<rect x="-14" y="-52" width="18" height="9" rx="2" fill="#f59e0b"/><rect x="-7" y="-53.5" width="4" height="2" rx="1" fill="#92400e"/>' +
'<rect x="5" y="-50" width="14" height="7" rx="2" fill="#38bdf8"/>' +
'<path d="M-18 -43.5L24 -43.5" stroke="#334155" stroke-width="1.6" stroke-linecap="round"/>' +
// Body, cabin and windows.
'<path d="M-44 -12L-44 -22Q-44 -28 -38 -29L-24 -30L-14 -42Q-12 -44 -8 -44L20 -44Q25 -44 28 -40L37 -30Q44 -29 45 -22L45 -12Q45 -7 40 -7L-39 -7Q-44 -7 -44 -12Z" fill="' + col + '"/>' +
'<path d="M-44 -18L45 -18L45 -12Q45 -7 40 -7L-39 -7Q-44 -7 -44 -12Z" fill="#000" opacity=".14"/>' +
'<path d="M-19 -30L-11 -40Q-10 -41 -8 -41L3 -41L3 -30Z" fill="#dbeafe"/>' +
'<path d="M7 -30L7 -41L19 -41Q22 -41 24 -38L31 -30Z" fill="#dbeafe"/>' +
'<path d="M-6 -39L-1 -39L-7 -32L-11 -32Z" fill="#fff" opacity=".55"/>' +
'<rect x="-2" y="-27" width="5" height="1.6" rx=".8" fill="#fff" opacity=".6"/>' +
'<rect x="40" y="-25" width="5" height="4" rx="1.5" fill="#fef08a"/>' +
'<rect x="-44" y="-25" width="3" height="5" rx="1" fill="#ef4444"/>' +
wheel(-25) + wheel(27) +
'</g></g></svg>';
html = html.replace('<g class="rdt-puffs">', '<g class="rdt-layer">' + tile(150, function (x) {
// Roadside marker posts in the foreground.
return '<rect x="' + (x + 40) + '" y="226" width="3.5" height="16" rx="1" fill="#f8fafc"/><rect x="' + (x + 40) + '" y="229" width="3.5" height="3" fill="#ef4444"/>' +
'<path d="M' + (x + 100) + ' 246q3 -8 5 0q2 -9 5 0q3 -7 4 0Z" fill="#3f9d55"/>';
}) + '</g><g class="rdt-puffs">');
el.innerHTML = html;
var svg = el.firstChild;
var groups = svg.querySelectorAll('.rdt-layer');
// Layer order in the DOM: clouds, mountains, hills, dashes, posts.
var lay = [];
for (var k = 0; k < groups.length; k++) lay.push({ el: groups[k], p: layers[k][0], f: layers[k][1] });
var car = svg.querySelector('.rdt-car');
var spins = svg.querySelectorAll('.rdt-spin');
var puffs = svg.querySelectorAll('.rdt-puffs circle');
function frame(t) {
var dist = t * V;
lay.forEach(function (l) {
var off = (dist * l.f) % l.p;
l.el.setAttribute('transform', 'translate(' + (-off).toFixed(2) + ' 0)');
});
var rot = (dist / 9 * 180 / Math.PI) % 360;
for (var i = 0; i < spins.length; i++) spins[i].setAttribute('transform', 'rotate(' + rot.toFixed(1) + ')');
var s = t / 1000;
var bob = 0.7 * Math.sin(s * 9) + 0.4 * Math.sin(s * 23);
car.setAttribute('transform', 'translate(0 ' + (Math.min(0, bob) * 0.9).toFixed(2) + ') rotate(' + (0.5 * Math.sin(s * 5.3)).toFixed(2) + ' 0 -9)');
for (var j = 0; j < puffs.length; j++) {
var a = ((s * 1.4 + j / puffs.length) % 1);
puffs[j].setAttribute('cx', (150 - a * 26).toFixed(1));
puffs[j].setAttribute('cy', (208 - a * 8).toFixed(1));
puffs[j].setAttribute('r', (2 + a * 5).toFixed(1));
puffs[j].setAttribute('opacity', (0.8 * (1 - a)).toFixed(2));
}
}
if (reduced()) { frame(1800); 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-road-trip-loop]').forEach(function (el) {
if (el._rdt) return; // idempotent
el._rdt = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-road-trip-loop></div>
<!-- a red car, as a wide banner: -->
<div data-road-trip-loop data-color="#ef4444" style="aspect-ratio: auto; height: 240px"></div>