Rocket Launch Progress
A rocket waits on its launch pad above a progress bar. As the bar fills it starts to rumble, its engine flame grows and smoke billows out from the pad, harder and harder, until at 100% it blasts off out of sight. On its own it fills, launches, lands again and starts over. Give it a data-value and it becomes a progress meter that launches when your task is done. Size it with font-size and colour it with color.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-rocket-launch-progress>where it should appear. It fills and launches on a loop. - Or, to show real progress, add
data-value(0 to 100) and update it from your code:rocket.dataset.value = 72. At 100 it lifts off.
Options
Add any of these attributes to the same element.
data-value | Turn it into a progress meter filled to this percentage. At 100 the rocket launches; set it lower again and the rocket comes back down. |
data-duration | Time to fill the bar when looping. Default 3600 ms. |
data-label="false" | Hide the percentage. |
data-smoke="false" | No smoke. |
It’s 6em wide and about 5.5em tall. It’s a progressbar for screen readers, with aria-valuenow kept up to date. It only moves while it’s on screen. With reduced motion the rocket sits still on its pad and the bar shows its value.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Rocket Launch Progress: a rocket stands on its launch pad above a
// progress bar. As the bar fills it starts to rumble, its engine flame
// grows and smoke billows out from the pad, harder and harder, until at
// 100% it blasts off out of sight. On its own it fills, launches, lands
// again and starts over. Give it a data-value and it becomes a progress
// meter that launches when the value reaches 100.
// Sized with font-size (6em wide, 6.2em tall), coloured with color.
// It only moves while it's on screen.
// Usage: <div data-rocket-launch-progress aria-label="Launching"></div>
// or: <div data-rocket-launch-progress data-value="40"></div>
// (then set data-value, 0 to 100, from your code as things progress;
// set it below 100 again and the rocket comes back down)
// Optional: data-duration (ms to fill when looping, default 3600),
// data-label="false" (hide the percentage), data-smoke="false" (no smoke).
var DEFAULTS = { duration: 3600 };
var css =
'.rlp{display:inline-block;width:6em;vertical-align:middle;text-align:left}' +
'.rlp-sky{position:relative;height:5em;overflow:hidden}' +
'.rlp-pad{position:absolute;left:50%;bottom:0;width:2.2em;height:.2em;margin-left:-1.1em;border-radius:.1em .1em 0 0;background:currentColor;opacity:.3}' +
'.rlp-rocket{position:absolute;left:50%;bottom:.2em;width:1.3em;height:2.2em;margin-left:-.65em;will-change:transform}' +
'.rlp-rocket svg{display:block;width:100%;height:100%;overflow:visible}' +
'.rlp-puff{position:absolute;bottom:.05em;left:50%;width:.5em;height:.5em;margin:0 0 0 -.25em;border-radius:50%;background:#cbd5e1;opacity:0;pointer-events:none}' +
'.rlp-row{display:flex;align-items:center;gap:.3em;margin-top:.25em}' +
'.rlp-track{position:relative;flex:1;height:.3em;border-radius:.15em;background:currentColor;overflow:hidden}' +
'.rlp-track::before{content:"";position:absolute;inset:0;background:#fff;opacity:.82}' +
'.rlp-fill{position:absolute;inset:0;border-radius:inherit;background:currentColor;transform-origin:0 50%;transform:scaleX(0)}' +
'.rlp-label{min-width:2.6em;font-size:.5em;font-weight:700;font-variant-numeric:tabular-nums;text-align:right;line-height:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function make(parent, tag, attrs) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
parent.appendChild(n);
return n;
}
function span(parent, cls) {
var s = document.createElement('span');
s.className = cls;
parent.appendChild(s);
return s;
}
function clampV(v) { return Math.max(0, Math.min(100, isFinite(v) ? v : 0)); }
function inOut(v) { return 0.5 - 0.5 * Math.cos(Math.PI * v); }
// A believable fill: a few bursts of different lengths with short stalls between.
function plan(duration) {
var stops = [0], n = 4, i;
for (i = 1; i < n; i++) stops.push(Math.round(100 * (i / n) + (Math.random() - 0.5) * 14));
stops.push(100);
var segs = [], total = 0;
for (i = 1; i < stops.length; i++) {
var w = (stops[i] - stops[i - 1]) / 100 * (0.7 + Math.random() * 0.6);
segs.push({ from: stops[i - 1], to: stops[i], ms: w, pause: i < stops.length - 1 ? 0.06 + Math.random() * 0.08 : 0 });
total += w + segs[segs.length - 1].pause;
}
segs.forEach(function (s) { s.ms *= duration / total; s.pause *= duration / total; });
return segs;
}
function build(el) {
var d = el.dataset;
var meter = d.value != null && d.value !== '';
el.classList.add('rlp');
el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.setAttribute('aria-valuemin', '0');
el.setAttribute('aria-valuemax', '100');
el.textContent = '';
var sky = span(el, 'rlp-sky');
sky.style.display = 'block';
sky.setAttribute('aria-hidden', 'true');
span(sky, 'rlp-pad');
var rocket = span(sky, 'rlp-rocket');
var svg = make(rocket, 'svg', { viewBox: '0 0 26 44' });
var flame = make(svg, 'g', {});
make(flame, 'path', { d: 'M8.5 40Q13 64 17.5 40Z', fill: '#f97316' });
make(flame, 'path', { d: 'M10.5 40Q13 55 15.5 40Z', fill: '#fde047' });
make(svg, 'path', { d: 'M7 27L1 37V43L7.5 38Z M19 27L25 37V43L18.5 38Z', fill: 'currentColor' });
make(svg, 'path', { d: 'M7 27L1 37V43L7.5 38Z M19 27L25 37V43L18.5 38Z', fill: '#000', opacity: 0.22 });
make(svg, 'rect', { x: 9, y: 37, width: 8, height: 3.5, rx: 1, fill: '#64748b' });
make(svg, 'path', { d: 'M13 1C21 8 21.5 26 19 38H7C4.5 26 5 8 13 1Z', fill: 'currentColor' });
make(svg, 'path', { d: 'M13 1C17 4.5 19 9 19.8 13H6.2C7 9 9 4.5 13 1Z', fill: '#fff', opacity: 0.25 });
make(svg, 'path', { d: 'M13 1C9 5 7.2 14 7.2 24 7.2 30 7.6 34 8.4 38H7C4.5 26 5 8 13 1Z', fill: '#fff', opacity: 0.18 });
make(svg, 'circle', { cx: 13, cy: 19, r: 4, fill: '#e0f2fe', stroke: '#fff', 'stroke-width': 1.6 });
make(svg, 'path', { d: 'M11 17.5A2.6 2.6 0 0 1 13.5 16', fill: 'none', stroke: '#fff', 'stroke-width': 1, 'stroke-linecap': 'round' });
var row = span(el, 'rlp-row');
var track = span(row, 'rlp-track');
track.setAttribute('aria-hidden', 'true');
var fill = span(track, 'rlp-fill');
var label = d.label === 'false' ? null : span(row, 'rlp-label');
if (label) label.setAttribute('aria-hidden', 'true');
var smoke = d.smoke !== 'false';
var shown = -1;
function setP(p) {
fill.style.transform = 'scaleX(' + (p / 100).toFixed(4) + ')';
var r = Math.floor(p);
if (r !== shown) {
shown = r;
el.setAttribute('aria-valuenow', String(r));
if (label) label.textContent = r + '%';
}
}
function pose(x, y, rot, flameScale) {
rocket.style.transform = 'translate(' + x.toFixed(3) + 'em,' + y.toFixed(3) + 'em) rotate(' + rot.toFixed(2) + 'deg)';
flame.setAttribute('transform', 'translate(13 40) scale(' + (0.6 + flameScale * 0.4).toFixed(3) + ' ' + flameScale.toFixed(3) + ') translate(-13 -40)');
flame.style.opacity = flameScale > 0.04 ? 1 : 0;
}
function puff(power, burst) {
if (!smoke) return;
var s = span(sky, 'rlp-puff');
var dir = Math.random() < 0.5 ? -1 : 1;
var size = 0.6 + Math.random() * 0.8 + power * 0.8;
anime.animate(s, {
x: { from: 0, to: dir * (0.6 + Math.random() * (burst ? 2.6 : 1.6)) + 'em' },
y: { from: 0, to: -(Math.random() * (0.3 + power * 0.5)) + 'em' },
scale: { from: 0.3, to: size },
opacity: { from: 0.85, to: 0 },
duration: 700 + Math.random() * 600,
ease: 'out(3)',
onComplete: function () { s.remove(); },
});
}
if (reduced()) {
var still = meter ? clampV(+d.value) : 64;
setP(still);
pose(0, 0, 0, 0);
if (meter) {
new MutationObserver(function () { setP(clampV(+d.value)); }).observe(el, { attributes: true, attributeFilter: ['data-value'] });
}
return null;
}
var duration = Math.max(800, +d.duration || DEFAULTS.duration);
// States: 'fill' (on the pad), 'launch' (flying away), 'gone', 'land' (coming back down).
var state = 'fill', st = 0, p = 0, segs = plan(duration), seg = 0, segT = 0, puffAcc = 0, shake = 0;
setP(0);
pose(0, 0, 0, 0.1);
function step(dt) {
st += dt;
if (state === 'fill') {
if (meter) {
p += (clampV(+d.value) - p) * Math.min(1, dt / 260);
if (clampV(+d.value) >= 100 && p > 99.5) p = 100;
} else {
var s = segs[seg];
segT += dt;
if (segT < s.ms) p = s.from + (s.to - s.from) * inOut(segT / s.ms);
else {
p = s.to;
if (segT >= s.ms + s.pause && seg < segs.length - 1) { seg++; segT = 0; }
}
}
setP(p);
var k = p / 100;
shake += dt;
if (shake > 34) {
shake = 0;
var amp = 0.006 + 0.05 * k * k;
pose((Math.random() - 0.5) * 2 * amp, (Math.random() - 0.5) * amp, (Math.random() - 0.5) * 6 * amp * 10, 0.12 + 0.5 * k + Math.random() * 0.12 * k);
}
puffAcc += dt * (k * k);
if (k > 0.25 && puffAcc > 70) { puffAcc = 0; puff(k, false); }
if (p >= 100) {
state = 'launch'; st = 0;
for (var i = 0; i < 8; i++) puff(1, true);
}
} else if (state === 'launch') {
var y = -0.55 * Math.pow(st / 100, 2) / 6;
pose((Math.random() - 0.5) * 0.03, y, 0, 1.05 + Math.random() * 0.25);
if (st < 400 && Math.random() < 0.4) puff(1, true);
if (y < -6) { state = 'gone'; st = 0; }
} else if (state === 'gone') {
if (meter) {
if (clampV(+d.value) < 100) { state = 'land'; st = 0; }
} else if (st > 700) {
setP(0);
p = 0;
state = 'land'; st = 0;
}
} else if (state === 'land') {
var u = Math.min(1, st / 1100);
var e = 1 - Math.pow(1 - u, 3);
pose(0, -6 * (1 - e), 0, u < 1 ? 0.3 + 0.4 * (1 - e) : 0.1);
if (meter) { p += (clampV(+d.value) - p) * Math.min(1, dt / 260); setP(p); }
if (u > 0.75 && Math.random() < 0.25) puff(0.3, false);
if (u >= 1) { state = 'fill'; st = 0; segs = plan(duration); seg = 0; segT = 0; }
}
}
var last = 0;
return 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;
step(dt);
},
onPause: function () { last = 0; },
});
}
function init() {
document.querySelectorAll('[data-rocket-launch-progress]').forEach(function (el) {
if (el._rlp) return;
el._rlp = true;
var timer = build(el);
if (!timer) return;
new IntersectionObserver(function (entries) {
entries[0].isIntersecting ? timer.play() : timer.pause();
}).observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-rocket-launch-progress style="font-size: 24px; color: #6a0dad" aria-label="Launching"></div>
<!-- as a progress meter: -->
<div data-rocket-launch-progress data-value="0" aria-label="Deploying"></div>
<script>
document.querySelector("[data-rocket-launch-progress]").dataset.value = 100; // lift-off
</script>