← Loaders

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

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-rocket-launch-progress> where it should appear. It fills and launches on a loop.
  3. 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-valueTurn 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-durationTime 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.

1 · Snippet
<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>
2 · Your HTML
<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>