Tool Call Chip
A little pill shows an AI tool at work: a spinner turns, a soft fill sweeps across the chip with a shine running through it, and a timer counts the seconds. When the tool is done the spinner pops into a green tick and the label changes to say what happened. Put it on a list and the chips run one after another, like an agent working through its tools.
What will the weather be like in Lisbon on Saturday?
- Searching the web
- Reading forecasts
- Averaging the highs
How to use it
- Paste the snippet just before
</head>. - Add
data-tool-call-chipto a<span>holding what the tool is doing, withdata-donefor the finished label. - Or put it on a
<ul>: each<li>becomes a chip and they run in turn. Items can have their owndata-done,data-iconanddata-duration.
Options
Add any of these attributes to the same element.
data-done | Label once the tool has finished. Default the same label. |
data-icon | search, globe, code, file, calc, image or none. Default search. |
data-duration | How long the tool runs. Default 2400 ms. |
data-gap | Pause between chips in a list. Default 250 ms. |
data-timer="false" | Hide the seconds counter. |
data-color | Colour of the fill and spinner. Default #7c3aed. |
data-loop | Run through again after a pause. |
data-hold | How long the finished chips show before looping. Default 2400 ms. |
To match a real tool call, leave out data-loop, set data-duration to roughly how long it takes, and call animacious.toolCallChip(el) each time the tool runs.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Tool Call Chip: a little pill that shows an AI tool at work. A spinner
// turns, a soft fill sweeps across the chip and a timer counts up; when the
// tool is done the spinner becomes a tick and the label changes to say so.
// Put it on a list and each item runs in turn, like an agent's tool calls.
// Usage: <span data-tool-call-chip data-done="Searched the web">Searching the web</span>
// or: <ul data-tool-call-chip><li data-icon="file" data-done="Read 3 files">Reading files</li>…</ul>
// Optional: data-done (label when finished, default the same label),
// data-icon (search, globe, code, file, calc, image or none; default search),
// data-duration (ms the tool runs, default 2400), data-gap (ms between list
// items, default 250), data-timer="false" (hide the seconds), data-color
// (fill and spinner colour, default #7c3aed), data-loop (run again),
// data-hold (ms the finished chips show before looping, default 2400).
// List items can set their own data-done, data-icon and data-duration.
// Script use: animacious.toolCallChip(el) runs it again.
var DEFAULTS = { icon: 'search', duration: 2400, gap: 250, color: '#7c3aed', hold: 2400 };
var css =
'.tcc-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;align-items:flex-start;gap:.55em}' +
'.tcc-chip{position:relative;display:inline-flex;align-items:center;gap:.5em;max-width:100%;padding:.42em .85em .42em .6em;' +
'border-radius:999px;overflow:hidden;isolation:isolate;line-height:1.2;white-space:nowrap;text-align:left;' +
'background:color-mix(in srgb,var(--tcc-c) 7%,transparent);border:1px solid color-mix(in srgb,var(--tcc-c) 22%,transparent);' +
'transition:border-color .4s,background-color .4s}' +
'.tcc-fill{position:absolute;inset:0;z-index:-1;transform-origin:0 50%;transform:scaleX(0);' +
'background:linear-gradient(90deg,color-mix(in srgb,var(--tcc-c) 6%,transparent),color-mix(in srgb,var(--tcc-c) 20%,transparent))}' +
'.tcc-fill::after{content:"";position:absolute;inset:0;opacity:0;background:linear-gradient(100deg,transparent 30%,' +
'color-mix(in srgb,#fff 55%,transparent) 50%,transparent 70%);background-size:250% 100%}' +
'.tcc-running .tcc-fill::after{opacity:1;animation:tcc-sweep 1.3s linear infinite}' +
'.tcc-icon{position:relative;flex:none;width:1.05em;height:1.05em}' +
'.tcc-spin{position:absolute;inset:.06em;border-radius:50%;border:.14em solid color-mix(in srgb,var(--tcc-c) 25%,transparent);' +
'border-top-color:var(--tcc-c);animation:tcc-spin .75s linear infinite}' +
'.tcc-tick{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;background:var(--tcc-ok);color:#fff;transform:scale(0)}' +
'.tcc-tool{flex:none;width:1em;height:1em;opacity:.7}' +
'.tcc-label{display:inline-grid;min-width:0}' +
'.tcc-label>span{grid-area:1/1;overflow:hidden;text-overflow:ellipsis}' +
'.tcc-done-l{opacity:0}' +
'.tcc-dots::after{content:"";display:inline-block;width:1.1em;text-align:left;animation:tcc-dots 1.2s steps(4) infinite}' +
'.tcc-time{flex:none;font-variant-numeric:tabular-nums;font-size:.85em;opacity:.55;min-width:2.6em;text-align:right}' +
'.tcc-ok{border-color:color-mix(in srgb,var(--tcc-ok) 35%,transparent)}' +
'.tcc-ok .tcc-spin{display:none}' +
'@keyframes tcc-spin{to{transform:rotate(360deg)}}' +
'@keyframes tcc-sweep{from{background-position:120% 0}to{background-position:-20% 0}}' +
'@keyframes tcc-dots{0%{content:""}25%{content:"."}50%{content:".."}75%{content:"..."}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function svg(d) {
return '<svg class="tcc-tool" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="' + d + '"/></svg>';
}
var ICONS = {
search: svg('M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13zM15.5 15.5L20 20'),
globe: svg('M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.6 3.7 5.6 3.7 9s-1.2 6.4-3.7 9c-2.5-2.6-3.7-5.6-3.7-9S9.5 5.6 12 3z'),
code: svg('M8.5 7L3.5 12l5 5M15.5 7l5 5-5 5M13.5 4.5l-3 15'),
file: svg('M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5M9 13h6M9 17h4'),
calc: svg('M7 3h10a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM8.5 7h7M8.5 12h.01M12 12h.01M15.5 12h.01M8.5 16h.01M12 16h.01M15.5 16h.01'),
image: svg('M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 20')
};
var TICK = '<svg class="tcc-tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.2" ' +
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 12.5l3.2 3.2L17 9"/></svg>';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function opt(chip, el, key) {
var v = chip.dataset[key];
if (v === undefined) v = el.dataset[key];
return v === undefined ? DEFAULTS[key] : v;
}
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<'); }
function build(chip, el) {
var label = chip.textContent.trim().replace(/(\.\.\.|…)$/, '');
var done = opt(chip, el, 'done') || label;
var icon = ICONS[opt(chip, el, 'icon')] || '';
var timer = el.dataset.timer !== 'false';
chip.classList.add('tcc-chip');
chip.setAttribute('role', 'status');
chip.innerHTML =
'<span class="tcc-fill" aria-hidden="true"></span>' +
'<span class="tcc-icon" aria-hidden="true"><span class="tcc-spin"></span>' + TICK + '</span>' + icon +
'<span class="tcc-label"><span class="tcc-run-l tcc-dots">' + esc(label) + '</span>' +
'<span class="tcc-done-l">' + esc(done) + '</span></span>' +
(timer ? '<span class="tcc-time" aria-hidden="true">0.0s</span>' : '');
return {
el: chip, duration: +opt(chip, el, 'duration') || DEFAULTS.duration,
fill: chip.querySelector('.tcc-fill'), tick: chip.querySelector('.tcc-tick'),
run: chip.querySelector('.tcc-run-l'), done: chip.querySelector('.tcc-done-l'),
time: chip.querySelector('.tcc-time'), anims: []
};
}
function setup(el) {
var list = el.tagName === 'UL' || el.tagName === 'OL';
var color = el.dataset.color || DEFAULTS.color;
el.style.setProperty('--tcc-c', color);
el.style.setProperty('--tcc-ok', '#16a34a');
if (list) el.classList.add('tcc-list');
var nodes = list ? [].slice.call(el.children) : [el];
el._tcc = { chips: nodes.map(function (n) { return build(n, el); }), timers: [], run: 0 };
}
function reset(c) {
c.anims.forEach(function (a) { a.pause(); });
c.anims = [];
c.el.classList.remove('tcc-ok', 'tcc-running');
anime.utils.set(c.fill, { scaleX: 0, opacity: 1 });
anime.utils.set(c.tick, { scale: 0 });
anime.utils.set(c.run, { opacity: 1, y: 0 });
anime.utils.set(c.done, { opacity: 0, y: 0 });
if (c.time) c.time.textContent = '0.0s';
}
function finished(c, secs) {
c.el.classList.add('tcc-ok');
c.el.classList.remove('tcc-running');
anime.utils.set(c.fill, { scaleX: 1 });
anime.utils.set(c.tick, { scale: 1 });
anime.utils.set(c.run, { opacity: 0 });
anime.utils.set(c.done, { opacity: 1 });
if (c.time) c.time.textContent = secs.toFixed(1) + 's';
}
function runChip(c, onDone) {
reset(c);
c.el.classList.add('tcc-running');
var start = performance.now();
// The fill grows across the chip while the tool runs; a timer keeps the clock.
c.anims.push(anime.animate(c.fill, { scaleX: [0, 1], duration: c.duration, ease: 'out(2)' }));
var tick = anime.createTimer({
duration: c.duration,
onUpdate: function () { if (c.time) c.time.textContent = ((performance.now() - start) / 1000).toFixed(1) + 's'; },
onComplete: function () {
if (c.time) c.time.textContent = (c.duration / 1000).toFixed(1) + 's';
c.el.classList.remove('tcc-running');
c.el.classList.add('tcc-ok');
c.anims.push(anime.animate(c.tick, { scale: [0, 1], rotate: [-90, 0], duration: 600, ease: 'outBack(2)' }));
c.anims.push(anime.animate(c.fill, { opacity: [1, 0.35], duration: 700, ease: 'out(2)' }));
c.anims.push(anime.animate(c.run, { opacity: 0, y: -8, duration: 260, ease: 'in(2)' }));
c.anims.push(anime.animate(c.done, { opacity: [0, 1], y: [8, 0], duration: 380, delay: 120, ease: 'out(3)' }));
c.anims.push(anime.animate(c.el, { scale: [1, 1.04, 1], duration: 420, ease: 'out(2)' }));
if (onDone) onDone();
}
});
c.anims.push(tick);
}
function play(el) {
var st = el._tcc, d = el.dataset;
var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var run = ++st.run;
st.timers.forEach(clearTimeout);
st.timers = [];
function later(fn, ms) { st.timers.push(setTimeout(function () { if (run === st.run) fn(); }, ms)); }
st.chips.forEach(function (c) { reset(c); if (st.chips.length > 1) anime.utils.set(c.el, { opacity: 0, y: 6 }); });
function next(i) {
if (i >= st.chips.length) {
if (d.loop !== undefined) later(function () {
anime.animate(st.chips.map(function (c) { return c.el; }), {
opacity: 0, y: -4, duration: 350, delay: anime.stagger(60), ease: 'in(2)',
onComplete: function () { if (run === st.run) later(function () { play(el); }, 250); }
});
}, hold);
return;
}
var c = st.chips[i];
if (st.chips.length > 1) anime.animate(c.el, { opacity: [0, 1], y: [6, 0], duration: 380, ease: 'out(3)' });
else anime.utils.set(c.el, { opacity: 1, y: 0 });
runChip(c, function () { later(function () { next(i + 1); }, gap); });
}
next(0);
}
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-tool-call-chip]').forEach(function (el) {
if (el._tcc) return;
setup(el);
if (reduced()) el._tcc.chips.forEach(function (c) { finished(c, c.duration / 1000); });
else io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.toolCallChip = function (el) { if (el && el._tcc) play(el); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><span data-tool-call-chip data-done="Searched the web">Searching the web</span>
<!-- several tools in turn: -->
<ul data-tool-call-chip>
<li data-done="Searched the web">Searching the web</li>
<li data-icon="file" data-done="Read 4 pages">Reading pages</li>
<li data-icon="code" data-done="Ran the code">Running code</li>
</ul>