← AI

Thinking Indicator

A shimmering “Thinking…” label with a bright band sweeping through the words. It rolls through status phrases as it works, a little sparkle turns beside it and a timer counts the seconds. Give it a finishing time and it settles on “Thought for 9s” with a tick.

Find me a quiet café near the station

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-thinking-indicator> where the answer will appear. It takes the text colour and size of its parent.
  3. Remove it (or swap in the answer) when the real work is done, or give it data-done to finish by itself.

Options

Add any of these attributes to the same element.

data-phrasesStatus phrases to cycle through, separated by |. Default “Thinking|Searching the web|Reading sources|Weighing the options|Drafting an answer”.
data-intervalHow long each phrase shows. Default 2400 ms.
data-timer="false"Hide the seconds counter.
data-doneFinish after this many ms, showing “Thought for …s” with a tick. Default 0 (keeps going).
data-loopStart over after finishing (needs data-done).
data-holdHow long the finished state shows before looping. Default 2500 ms.
data-shineColour of the sweeping band. Default the text colour, with the rest of the label dimmed.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Thinking Indicator: a shimmering "Thinking…" label, the kind an AI shows
    // while it works. A bright band sweeps through the words, the label rolls
    // through status phrases, a little sparkle turns beside it, and a timer
    // counts the seconds. Give it a finishing time and it settles on
    // "Thought for 9s" with a tick.
    // Usage: <div data-thinking-indicator></div>
    // Optional: data-phrases (separated by |, default "Thinking|Searching the
    // web|Reading sources|Weighing the options|Drafting an answer"),
    // data-interval (ms per phrase, default 2400), data-timer="false" (hide the
    // timer), data-done (ms until it finishes, default 0 = keeps going),
    // data-loop (start over after finishing), data-hold (ms the finished state
    // shows before looping, default 2500), data-shine (colour of the sweeping
    // band, default the text colour; the rest of the label is dimmed).
    var DEFAULTS = {
      phrases: 'Thinking|Searching the web|Reading sources|Weighing the options|Drafting an answer',
      interval: 2400, done: 0, hold: 2500
    };

    var css =
      '[data-thinking-indicator]{display:inline-flex;align-items:center;gap:.5em;white-space:nowrap;line-height:1.3}' +
      '.thk-icon{flex:none;width:1.05em;height:1.05em;display:grid;place-items:center}' +
      '.thk-icon svg{width:100%;height:100%;display:block}' +
      '.thk-spin{animation:thk-turn 2.4s cubic-bezier(.6,0,.4,1) infinite;transform-origin:50% 50%}' +
      '.thk-labels{position:relative;display:inline-grid;overflow:hidden;transition:width .45s cubic-bezier(.3,.7,.3,1)}' +
      '.thk-label{grid-area:1/1;justify-self:start;width:max-content;padding:.1em 0;' +
      'background:linear-gradient(100deg,var(--thk-dim) 0 38%,var(--thk-shine) 50%,var(--thk-dim) 62% 100%);' +
      'background-size:260% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;' +
      'animation:thk-sweep 1.9s linear infinite}' +
      '.thk-done .thk-label{animation:none;background:none;-webkit-text-fill-color:currentColor}' +
      '.thk-time{opacity:.55;font-variant-numeric:tabular-nums}' +
      '@keyframes thk-sweep{from{background-position:100% 0}to{background-position:0 0}}' +
      '@keyframes thk-turn{to{transform:rotate(180deg)}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var SPARK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path class="thk-spin" fill="currentColor" ' +
      'd="M12 1.5c.5 4.8 2.2 7.6 3.9 8.6 1.6 1 3.8 1.5 6.6 1.9-2.8.4-5 .9-6.6 1.9-1.7 1-3.4 3.8-3.9 8.6-.5-4.8-2.2-7.6-3.9-8.6-1.6-1-3.8-1.5-6.6-1.9 2.8-.4 5-.9 6.6-1.9 1.7-1 3.4-3.8 3.9-8.6z"/></svg>';
    var TICK = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.6" ' +
      'stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var phrases = (d.phrases || DEFAULTS.phrases).split('|').map(function (p) { return p.trim(); }).filter(Boolean);
      var shine = d.shine || 'currentColor';
      el.style.setProperty('--thk-shine', shine);
      el.style.setProperty('--thk-dim', 'color-mix(in srgb, currentColor 42%, transparent)');
      el.setAttribute('role', 'status');
      el.innerHTML = '<span class="thk-icon">' + SPARK + '</span><span class="thk-labels"></span>' +
        (d.timer === 'false' ? '' : '<span class="thk-time" aria-hidden="true">0s</span>');
      el._thk = {
        phrases: phrases, icon: el.querySelector('.thk-icon'), box: el.querySelector('.thk-labels'),
        time: el.querySelector('.thk-time'), cur: null, timers: [], run: 0
      };
      show(el, phrases[0] + '…', false);
    }

    // Swap the label: the old phrase rolls up and out, the new one rolls in.
    function show(el, text, animate) {
      var st = el._thk;
      var s = document.createElement('span');
      s.className = 'thk-label';
      s.textContent = text;
      var old = st.cur;
      var from = st.box.offsetWidth;
      st.box.appendChild(s);
      st.cur = s;
      var to = s.offsetWidth;
      if (!animate || !old) {
        if (old) old.remove();
        st.box.style.width = to + 'px';
        return;
      }
      st.box.style.width = from + 'px';
      st.box.offsetWidth; // commit the start width so the change transitions
      st.box.style.width = to + 'px';
      anime.animate(old, { y: ['0%', '-100%'], opacity: [1, 0], duration: 380, ease: 'in(2)', onComplete: function () { old.remove(); } });
      anime.animate(s, { y: ['100%', '0%'], opacity: [0, 1], duration: 480, delay: 120, ease: 'out(3)' });
    }

    function clear(st) { st.timers.forEach(function (t) { clearTimeout(t); clearInterval(t); }); st.timers = []; }

    function play(el) {
      var st = el._thk, d = el.dataset;
      var interval = +d.interval || DEFAULTS.interval;
      var done = +d.done || DEFAULTS.done;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var run = ++st.run;
      clear(st);
      el.classList.remove('thk-done');
      st.icon.innerHTML = SPARK;
      show(el, st.phrases[0] + '…', !!st.cur && st.cur.textContent !== st.phrases[0] + '…');
      var start = Date.now(), i = 0;
      if (st.time) st.time.textContent = '0s';
      st.timers.push(setInterval(function () {
        if (st.time) st.time.textContent = Math.floor((Date.now() - start) / 1000) + 's';
      }, 250));
      if (st.phrases.length > 1) {
        st.timers.push(setInterval(function () {
          i = (i + 1) % st.phrases.length;
          show(el, st.phrases[i] + '…', true);
        }, interval));
      }
      if (done) {
        st.timers.push(setTimeout(function () {
          if (run !== st.run) return;
          clear(st);
          var secs = Math.max(1, Math.round((Date.now() - start) / 1000));
          el.classList.add('thk-done');
          st.icon.innerHTML = TICK;
          anime.animate(st.icon, { scale: [0.4, 1], opacity: [0, 1], duration: 500, ease: 'outBack(2)' });
          if (st.time) st.time.textContent = '';
          show(el, 'Thought for ' + secs + 's', true);
          if (d.loop !== undefined) {
            st.timers.push(setTimeout(function () { if (run === st.run) play(el); }, hold));
          }
        }, done));
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-thinking-indicator]').forEach(function (el) {
        if (el._thk) return;
        setup(el);
        if (reduced()) {
          el.classList.add('thk-done');
          el.querySelector('.thk-spin').style.animation = 'none';
        } else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-thinking-indicator></div>

<!-- your own phrases, finishing after 8 seconds: -->
<div data-thinking-indicator data-phrases="Thinking|Checking the docs|Writing code" data-done="8000"></div>