← Typing

Install Progress Log

A terminal runs an install. The command types in, then each package gets a ticking braille spinner and a text progress bar that fills at its own uneven pace, a few at a time. Finished packages tick green and a summary prints at the end.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-install-progress-log to a container with one element per package name.

Options

Add any of these attributes to the same element.

data-commandThe command typed first. Default npm install.
data-doneThe summary line. {n} is the package count and {t} the seconds taken. Default “added {n} packages in {t}s”.
data-parallelHow many packages install at once. Default 3.
data-speedSpeed multiplier. Default 1; 2 is twice as fast.
data-promptThe prompt before the command. Default $.
data-titleText in the window’s title bar. Default “Terminal”.
data-loopClear and run the install again after a pause.
data-holdHow long the finished log stays before looping. Default 2600 ms.

Every line’s space is kept from the start, so the window never grows. The install holds still while it is scrolled off screen.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Install Progress Log: a terminal installs packages. The command types in,
    // then each package gets a ticking spinner and a text progress bar that fills
    // at its own uneven pace, a few at a time, before a summary line prints.
    // Usage:
    //   <div data-install-progress-log data-command="npm install">
    //     <p>[email protected]</p>
    //     <p>[email protected]</p>
    //   </div>
    // One element per package.
    // Optional: data-command (typed first, default "npm install"), data-done
    // (summary line; {n} is the package count and {t} the seconds taken, default
    // "added {n} packages in {t}s"), data-prompt (default "$"), data-title
    // (window title, default "Terminal"), data-parallel (packages at once,
    // default 3), data-speed (multiplier, default 1; 2 is twice as fast),
    // data-loop (clear and replay), data-hold (ms before replaying, default 2600).
    var DEFAULTS = { command: 'npm install', done: 'added {n} packages in {t}s', prompt: '$', title: 'Terminal', parallel: 3, speed: 1, hold: 2600 };
    var SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
    var CELLS = 10;

    var css =
      '.ipl{background:#101015;color:#e4e4e7;border-radius:12px;overflow:hidden;text-align:left;box-shadow:0 18px 40px rgba(10,10,20,.28);' +
      'font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' +
      '.ipl-bar{display:flex;align-items:center;gap:6px;padding:0.7em 0.9em;background:#1b1b22;font-size:0.8em;color:#8a8a93}' +
      '.ipl-bar i{width:0.8em;height:0.8em;border-radius:50%;background:#ff5f57;flex:none}' +
      '.ipl-bar i:nth-child(2){background:#febc2e}.ipl-bar i:nth-child(3){background:#28c840}' +
      '.ipl-bar span{flex:1;text-align:center;padding-right:3em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.ipl-body{padding:0.8em 1.1em 1em}' +
      '.ipl-body>*{margin:0;line-height:1.6;min-height:1.6em;white-space:pre}' +
      '.ipl-prompt{color:#4ade80;margin-right:0.6em}' +
      '.ipl-row{display:flex;gap:0.6em;align-items:center}' +
      '.ipl-spin{flex:none;width:1em;text-align:center;color:#c084fc}' +
      '.ipl-row.ipl-ok .ipl-spin{color:#4ade80}' +
      '.ipl-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;color:#d4d4d8}' +
      '.ipl-meter{flex:none;color:#c084fc;letter-spacing:-0.05em}' +
      '.ipl-meter b{font-weight:400;color:#52525b}' +
      '.ipl-row.ipl-ok .ipl-meter{color:#4ade80}' +
      '.ipl-pct{flex:none;width:4ch;text-align:right;color:#8a8a93;font-variant-numeric:tabular-nums}' +
      '.ipl-sum{color:#4ade80}' +
      '.ipl-cursor{display:inline-block;width:0.55em;height:1.15em;vertical-align:-0.2em;background:#e4e4e7;margin-left:1px}' +
      '.ipl-cursor.ipl-blink{animation:ipl-blink 1.06s steps(1) infinite}@keyframes ipl-blink{50%{opacity:0}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
    function opt(d, k) { return d[k] !== undefined ? d[k] : DEFAULTS[k]; }

    function line(cls, html) {
      var p = document.createElement('p');
      if (cls) p.className = cls;
      if (html) p.innerHTML = html;
      return p;
    }

    function setup(el) {
      var d = el.dataset;
      var names = Array.from(el.children).map(function (c) { return c.textContent.trim(); });
      el.textContent = '';
      el.classList.add('ipl');
      var bar = document.createElement('div');
      bar.className = 'ipl-bar';
      bar.setAttribute('aria-hidden', 'true');
      bar.innerHTML = '<i></i><i></i><i></i><span></span>';
      bar.lastChild.textContent = opt(d, 'title');
      el.appendChild(bar);
      var body = document.createElement('div');
      body.className = 'ipl-body';
      el.appendChild(body);
      var cmd = line('', '<span class="ipl-prompt"></span><span class="ipl-cmd"></span>');
      cmd.firstChild.textContent = opt(d, 'prompt');
      body.appendChild(cmd);
      var rows = names.map(function (name) {
        var p = line('ipl-row', '<span class="ipl-spin" aria-hidden="true"></span><span class="ipl-name"></span>' +
          '<span class="ipl-meter" aria-hidden="true"></span><span class="ipl-pct" aria-hidden="true"></span>');
        p.children[1].textContent = name;
        body.appendChild(p);
        return { el: p, spin: p.children[0], meter: p.children[2], pct: p.children[3], p: 0, rate: 0, f: 0 };
      });
      var sum = line('ipl-sum');
      body.appendChild(sum);
      var end = line('', '<span class="ipl-prompt"></span>');
      end.firstChild.textContent = opt(d, 'prompt');
      body.appendChild(end);
      var cursor = document.createElement('span');
      cursor.className = 'ipl-cursor';
      cursor.setAttribute('aria-hidden', 'true');
      return {
        el: el, cmdLine: cmd, cmd: cmd.lastChild, command: opt(d, 'command'), rows: rows, sum: sum, end: end, cursor: cursor,
        visible: false, wake: null
      };
    }

    function draw(r) {
      var full = Math.round(r.p * CELLS);
      r.meter.innerHTML = '█'.repeat(full) + '<b>' + '░'.repeat(CELLS - full) + '</b>';
      r.pct.textContent = Math.round(r.p * 100) + '%';
      r.spin.textContent = r.p >= 1 ? '✓' : SPIN[r.f % SPIN.length];
      r.el.classList.toggle('ipl-ok', r.p >= 1);
    }

    function summary(s, secs) {
      return opt(s.el.dataset, 'done').replace('{n}', s.rows.length).replace('{t}', secs.toFixed(1));
    }

    // Everything finished (reduced motion, and to reserve the box's height).
    function finished(s) {
      s.cmd.textContent = s.command;
      s.rows.forEach(function (r) { r.p = 1; draw(r); r.el.style.visibility = ''; });
      s.sum.textContent = '✓ ' + summary(s, 2.4);
      [s.cmdLine, s.sum, s.end].forEach(function (l) { l.style.visibility = ''; });
      s.end.appendChild(s.cursor);
    }

    function whenVisible(s) {
      return s.visible ? Promise.resolve() : new Promise(function (r) { s.wake = r; });
    }

    function typeCommand(s) {
      return new Promise(function (done) {
        var n = 0;
        s.cmdLine.appendChild(s.cursor);
        s.cursor.classList.remove('ipl-blink');
        (function tick() {
          s.cmd.textContent = s.command.slice(0, n);
          if (n++ >= s.command.length) { done(); return; }
          setTimeout(tick, 45 * (0.6 + Math.random() * 0.8));
        })();
      });
    }

    function install(s, k) {
      var d = s.el.dataset;
      var parallel = Math.max(1, +opt(d, 'parallel'));
      return new Promise(function (done) {
        var started = 0, finishedCount = 0, t0 = Date.now(), last = t0, paused = 0;
        function start() {
          var r = s.rows[started++];
          r.p = 0;
          r.f = 0;
          r.rate = (0.5 + Math.random() * 0.7) * k;   // fraction per second
          r.el.style.visibility = '';
          draw(r);
          anime.animate(r.el, { opacity: [0, 1], x: [-6, 0], duration: 260, ease: 'out(2)' });
        }
        for (var i = 0; i < Math.min(parallel, s.rows.length); i++) start();
        var timer = setInterval(function () {
          var now = Date.now(), dt = (now - last) / 1000;
          last = now;
          if (!s.visible) { paused += dt; return; }   // hold still off screen
          s.rows.forEach(function (r) {
            if (r.el.style.visibility || r.p >= 1) return;
            r.f++;
            // Uneven progress: mostly steady, with the odd stall and spurt.
            var burst = Math.random() < 0.12 ? 0 : Math.random() < 0.15 ? 3 : 1;
            r.p = Math.min(1, r.p + r.rate * dt * burst * (0.6 + Math.random() * 0.8));
            draw(r);
            if (r.p >= 1) {
              finishedCount++;
              anime.animate(r.spin, { scale: [1.6, 1], duration: 400, ease: 'outBack(2)' });
              if (started < s.rows.length) start();
            }
          });
          if (finishedCount >= s.rows.length) {
            clearInterval(timer);
            done((now - t0) / 1000 - paused);
          }
        }, 80);
      });
    }

    function play(s) {
      var d = s.el.dataset;
      var k = +opt(d, 'speed') || 1;
      s.cmd.textContent = '';
      s.rows.forEach(function (r) { r.el.style.visibility = 'hidden'; });
      s.sum.textContent = '';
      s.end.style.visibility = 'hidden';
      sleep(400).then(function () { return typeCommand(s); })
        .then(function () { s.cursor.remove(); return sleep(350); })
        .then(function () { return install(s, k); })
        .then(function (secs) {
          s.sum.textContent = '✓ ' + summary(s, secs);
          anime.animate(s.sum, { opacity: [0, 1], duration: 300, ease: 'out(2)' });
          return sleep(300);
        })
        .then(function () {
          s.end.style.visibility = '';
          s.end.appendChild(s.cursor);
          s.cursor.classList.add('ipl-blink');
          if (d.loop === undefined || d.loop === 'false') return;
          return sleep(+opt(d, 'hold')).then(function () { return whenVisible(s); }).then(function () { play(s); });
        });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._ipl;
          s.visible = e.isIntersecting;
          if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-install-progress-log]').forEach(function (el) {
        if (el._ipl) return;
        var s = (el._ipl = setup(el));
        finished(s);
        if (reduced()) { s.cursor.classList.add('ipl-blink'); return; }
        io.observe(el);
        whenVisible(s).then(function () { play(s); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-install-progress-log data-command="npm install">
  <p>[email protected]</p>
  <p>[email protected]</p>
  <p>[email protected]</p>
</div>