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
- Paste the snippet just before
</head>. - Add an empty
<div data-thinking-indicator>where the answer will appear. It takes the text colour and size of its parent. - Remove it (or swap in the answer) when the real work is done, or give it
data-doneto finish by itself.
Options
Add any of these attributes to the same element.
data-phrases | Status phrases to cycle through, separated by |. Default “Thinking|Searching the web|Reading sources|Weighing the options|Drafting an answer”. |
data-interval | How long each phrase shows. Default 2400 ms. |
data-timer="false" | Hide the seconds counter. |
data-done | Finish after this many ms, showing “Thought for …s” with a tick. Default 0 (keeps going). |
data-loop | Start over after finishing (needs data-done). |
data-hold | How long the finished state shows before looping. Default 2500 ms. |
data-shine | Colour of the sweeping band. Default the text colour, with the rest of the label dimmed. |
<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><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>