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
- Paste the snippet just before
</head>. - Add
data-install-progress-logto a container with one element per package name.
Options
Add any of these attributes to the same element.
data-command | The command typed first. Default npm install. |
data-done | The summary line. {n} is the package count and {t} the seconds taken. Default “added {n} packages in {t}s”. |
data-parallel | How many packages install at once. Default 3. |
data-speed | Speed multiplier. Default 1; 2 is twice as fast. |
data-prompt | The prompt before the command. Default $. |
data-title | Text in the window’s title bar. Default “Terminal”. |
data-loop | Clear and run the install again after a pause. |
data-hold | How 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.
<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><div data-install-progress-log data-command="npm install">
<p>[email protected]</p>
<p>[email protected]</p>
<p>[email protected]</p>
</div>