← Typing

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

  1. Paste the snippet just before </head>.
  2. Add data-terminal-spinners to 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-spinnersThe style: dots (default), braille, bounce, line, arrows, bar, blocks, circle, ellipsis or progress.
data-intervalTime per frame. Each style has its own default, 80–300 ms.
data-durationFinish with a tick after this long. Without it, it spins for ever.
data-doneText that replaces the label once finished.
data-loopSpin again after finishing.
data-holdHow long the tick stays before looping. Default 1600 ms.
--tsp-color, --tsp-doneCSS 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.

1 · Snippet
<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>
2 · Your HTML
<span data-terminal-spinners="dots">Building</span>

<!-- finish after 3 seconds: -->
<span data-terminal-spinners="braille" data-duration="3000" data-done="Built">Building</span>