Terminal Spinners
Ten text-only loading spinners for terminal-style UIs, made of braille dots, bars, arrows, blocks and brackets. Each sits in front of its label at a fixed width, so nothing shifts, and can finish with a green tick after a set time.
Installingdots
Compilingbraille
Lintingbounce
Testingline
Syncingarrows
Uploadingbar
Indexingblocks
Resolvingcircle
Thinkingellipsis
Deployingprogress
How to use it
- Paste the snippet just before
</head>. - Add
data-terminal-spinnersto a label, with a style name as its value. The spinner goes in front of the text.
Options
Add any of these attributes to the same element.
data-terminal-spinners | The style: dots (default), braille, bounce, line, arrows, bar, blocks, circle, ellipsis or progress. |
data-interval | Time per frame. Each style has its own default, 80–300 ms. |
data-duration | Finish with a tick after this long. Without it, it spins for ever. |
data-done | Text that replaces the label once finished. |
data-loop | Spin again after finishing. |
data-hold | How long the tick stays before looping. Default 1600 ms. |
--tsp-color, --tsp-done | CSS variables for the spinner and tick colours. |
Spinners only tick while on screen, and hold a still frame with reduced motion turned on. From a script, animacious.terminalSpinners.done(el) finishes one when your task does.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Terminal Spinners: text-based loading spinners made of braille dots, bars,
// arrows and blocks, for terminal-style and monospace UIs. The spinner is
// put in front of the element's text and ticks while it is on screen; give
// it a duration and it finishes with a tick.
// Usage:
// <span data-terminal-spinners="dots">Building</span>
// Styles: dots (default), braille, bounce, line, arrows, bar, blocks, circle,
// ellipsis, progress.
// Optional: data-interval (ms per frame; each style has its own default),
// data-duration (ms before it finishes; without it, it spins for ever),
// data-done (text shown once finished), data-loop (spin again after
// finishing), data-hold (ms the finished state stays before looping,
// default 1600), --tsp-color and --tsp-done (CSS variables for the spinner
// and tick colours).
// Script use: animacious.terminalSpinners.done(el) finishes one now.
var DEFAULTS = { hold: 1600 };
var STYLES = {
dots: { f: '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏', ms: 80 },
braille: { f: '⣾⣽⣻⢿⡿⣟⣯⣷', ms: 80 },
bounce: { f: '⠁⠂⠄⡀⢀⠠⠐⠈', ms: 100 },
line: { f: '-\\|/', ms: 120 },
arrows: { f: '←↖↑↗→↘↓↙', ms: 100 },
bar: { f: '▁▂▃▄▅▆▇█▇▆▅▄▃▂', ms: 80 },
blocks: { f: '▖▘▝▗', ms: 120 },
circle: { f: '◐◓◑◒', ms: 120 },
ellipsis: { f: [' ', '. ', '.. ', '...'], ms: 300 },
progress: { f: ['[ ]', '[= ]', '[== ]', '[=== ]', '[ ===]', '[ ==]', '[ =]'], ms: 110 }
};
var css =
'.tsp-g{display:inline-block;width:1ch;margin-right:0.8ch;white-space:pre;text-align:center;color:var(--tsp-color,#c084fc);' +
'font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"DejaVu Sans Mono",monospace;font-variant-ligatures:none}' +
'.tsp-ok{color:var(--tsp-done,#4ade80)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function opt(d, k) { return d[k] !== undefined && d[k] !== '' ? d[k] : DEFAULTS[k]; }
function frames(st) { return typeof st.f === 'string' ? Array.from(st.f) : st.f; }
function setup(el) {
var d = el.dataset;
var st = STYLES[d.terminalSpinners] || STYLES.dots;
var f = frames(st);
var g = document.createElement('span');
g.className = 'tsp-g';
g.setAttribute('aria-hidden', 'true');
// Reserve the widest frame so the text after it never shifts.
g.style.width = Math.max.apply(null, f.map(function (x) { return x.length; })) + 'ch';
g.textContent = f[0];
var label = document.createElement('span');
while (el.firstChild) label.appendChild(el.firstChild);
el.appendChild(g);
el.appendChild(label);
return {
el: el, d: d, g: g, label: label, text: label.innerHTML, frames: f, ms: +d.interval || st.ms,
i: 0, timer: 0, visible: false, started: 0, elapsed: 0, finished: false, wait: 0
};
}
function draw(s) { s.g.textContent = s.frames[s.i % s.frames.length]; }
function finish(s) {
stop(s);
s.finished = true;
s.g.classList.add('tsp-ok');
s.g.textContent = '✓';
if (s.d.done) s.label.textContent = s.d.done;
if (!reduced()) anime.animate(s.g, { scale: [0.4, 1], duration: 360, ease: 'outBack(2.4)' });
if (s.d.loop !== undefined && s.d.loop !== 'false') {
clearTimeout(s.wait);
s.wait = setTimeout(function () { restart(s); }, +opt(s.d, 'hold'));
}
}
function restart(s) {
s.finished = false;
s.elapsed = 0;
s.i = 0;
s.g.classList.remove('tsp-ok');
s.label.innerHTML = s.text;
draw(s);
if (s.visible) start(s);
}
function start(s) {
if (s.timer || s.finished || reduced()) return;
s.started = Date.now();
var dur = +s.d.duration || 0;
s.timer = setInterval(function () {
s.i++;
draw(s);
if (dur && s.elapsed + (Date.now() - s.started) >= dur) finish(s);
}, s.ms);
}
function stop(s) {
if (!s.timer) return;
clearInterval(s.timer);
s.timer = 0;
s.elapsed += Date.now() - s.started;
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._tsp;
s.visible = e.isIntersecting;
if (s.visible) start(s); else stop(s);
});
}, { threshold: 0 });
document.querySelectorAll('[data-terminal-spinners]').forEach(function (el) {
if (el._tsp) return;
el._tsp = setup(el);
io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.terminalSpinners = {
done: function (el) { if (el && el._tsp && !el._tsp.finished) finish(el._tsp); },
restart: function (el) { if (el && el._tsp) restart(el._tsp); }
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><span data-terminal-spinners="dots">Building</span>
<!-- finish after 3 seconds: -->
<span data-terminal-spinners="braille" data-duration="3000" data-done="Built">Building</span>