Token Heat Stream
A reply streams in token by token, and every token is shaded by how sure the model was of it. Confident words barely have a tint, while shaky ones glow amber to red and the stream hesitates a moment before them. A small key underneath reads low to high, and hovering or tapping a token shows its probability.
What’s the tallest mountain in the solar system?
The tallest is Olympus Mons on Mars. It rises about 22 km above the plains around it, roughly two and a half times the height of Everest, and is so wide that its slopes would curve away over the horizon.
How to use it
- Paste the snippet just before
</head>. - Add
data-token-heat-streamto a paragraph. Words and pieces of longer words become tokens, each with a made-up but steady confidence. - Wrap a word in
<span data-p="0.3">to set its confidence yourself, from 0 to 1.
Options
Add any of these attributes to the same element.
data-p | On a <span> inside: the token’s confidence, 0 to 1. |
data-rate | Tokens a second. Default 14. |
data-legend="false" | Hide the low-to-high key. |
data-loop | Clear and stream again after a pause. |
data-hold | How long the finished text shows before looping. Default 2600 ms. |
Screen readers get the whole text straight away, as the element’s label.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Token Heat Stream: text streams in token by token, like a model's reply,
// and each token is shaded by how confident the model was in it: the sure
// ones barely tinted, the shaky ones glowing amber to red. Hover or tap a
// token to see its probability.
// Usage: <p data-token-heat-stream>The capital of Australia is <span data-p="0.41">Canberra</span>.</p>
// Tokens are words and pieces of longer words. Each gets a made-up but
// steady confidence (short common words high, long words lower); wrap a
// word in <span data-p="0.32"> to set its own (0 to 1).
// Optional: data-rate (tokens a second, default 14), data-legend="false"
// (hide the low-to-high key), data-loop (clear and stream again),
// data-hold (ms the finished text shows before looping, default 2600).
// Script use: animacious.tokenHeatStream(el) streams it again.
var DEFAULTS = { rate: 14, hold: 2600 };
var css =
'.ths{position:relative}' +
'.ths-text{display:block}' +
'.ths-tok{position:relative;border-radius:.25em;padding:.04em 0;box-decoration-break:clone;-webkit-box-decoration-break:clone;' +
'background:var(--ths-bg);box-shadow:inset 0 -.12em 0 var(--ths-line);display:none;cursor:default}' +
'.ths-tok.ths-in{display:inline;animation:ths-in .45s cubic-bezier(.2,.7,.3,1) both}' +
'.ths-tok:hover,.ths-tok.ths-tip{outline:1.5px solid var(--ths-line);z-index:2}' +
'.ths-tok:hover::after,.ths-tok.ths-tip::after{content:attr(data-pct);position:absolute;left:50%;bottom:calc(100% + .35em);transform:translateX(-50%);' +
'padding:.15em .45em;border-radius:.4em;background:#1c1530;color:#fff;font-size:.7em;font-weight:700;white-space:nowrap;pointer-events:none}' +
'.ths-caret{display:inline-block;width:.5em;height:.5em;margin-left:.15em;border-radius:50%;background:currentColor;vertical-align:.05em;' +
'animation:ths-pulse .9s ease-in-out infinite}' +
'.ths-key{display:flex;align-items:center;gap:.5em;margin-top:.9em;font-size:.72em;font-weight:600;opacity:.7}' +
'.ths-key i{flex:0 1 8em;height:.55em;border-radius:99px;background:linear-gradient(90deg,hsl(0 85% 60%),hsl(38 95% 58%),hsl(140 60% 55% / .35))}' +
'@keyframes ths-in{from{opacity:0;filter:blur(3px);background:var(--ths-flash)}to{opacity:1;filter:none}}' +
'@keyframes ths-pulse{50%{transform:scale(.55);opacity:.5}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<'); }
// A steady pseudo-random number for a token, so it shades the same each time.
function hash(s) {
var h = 2166136261;
for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 1000) / 1000;
}
function guess(tok, i) {
var w = tok.replace(/[^A-Za-z0-9]/g, '');
if (!w) return 0.97;
var r = hash(tok.toLowerCase() + i);
var p = w.length <= 3 ? 0.9 + r * 0.09 : 0.985 - Math.pow(r, 6) * 0.55 - Math.min(0.08, w.length * 0.006);
return Math.max(0.08, Math.min(0.99, p));
}
// Shade: green-ish and faint when sure, amber then red and strong when not.
function shade(p) {
var u = 1 - p; // uncertainty
var hue = Math.round(140 - Math.min(1, u * 1.6) * 140);
var a = (0.1 + Math.min(1, u * 1.5) * 0.45).toFixed(2);
return {
bg: 'hsl(' + hue + ' 90% 55% / ' + a + ')',
line: 'hsl(' + hue + ' 80% 45% / ' + (0.25 + u * 0.75).toFixed(2) + ')',
flash: 'hsl(' + hue + ' 95% 60% / .8)'
};
}
// Split a run of text into tokens: whole short words, longer words in two
// or three pieces, and spaces kept on the front of the next word.
function tokenize(text) {
var out = [];
(text.match(/\s*[^\s]+|\s+$/g) || []).forEach(function (chunk) {
var m = chunk.match(/^(\s*)(.*)$/);
var lead = m[1], word = m[2];
if (word.length > 7 && /^[A-Za-z]+$/.test(word)) {
var cut = Math.ceil(word.length * (word.length > 11 ? 0.4 : 0.55));
out.push(lead + word.slice(0, cut));
var rest = word.slice(cut);
if (rest.length > 6) { out.push(rest.slice(0, 4)); out.push(rest.slice(4)); }
else out.push(rest);
} else {
var mm = word.match(/^(.*?)([.,!?;:]+)$/);
if (mm && mm[1]) { out.push(lead + mm[1]); out.push(mm[2]); }
else out.push(chunk);
}
});
return out;
}
function setup(el) {
var d = el.dataset;
var toks = [];
// Walk the content: plain text is tokenised, <span data-p> keeps its value.
[].slice.call(el.childNodes).forEach(function (n) {
if (n.nodeType === 3) tokenize(n.textContent).forEach(function (t) { toks.push({ t: t }); });
else if (n.nodeType === 1) {
var p = n.dataset && n.dataset.p !== undefined ? +n.dataset.p : undefined;
tokenize(n.textContent).forEach(function (t, j) { toks.push({ t: t, p: j === 0 ? p : undefined, set: p !== undefined }); });
}
});
var label = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', label);
el.classList.add('ths');
var html = '<span class="ths-text" aria-hidden="true">';
toks.forEach(function (tk, i) {
var t = tk.t, lead = t.match(/^\s*/)[0], body = t.slice(lead.length);
if (!body) { html += esc(lead); return; }
var p = tk.p !== undefined ? tk.p : (tk.set ? 0.95 : guess(body, i));
var s = shade(p);
html += esc(lead) + '<span class="ths-tok" data-pct="' + Math.round(p * 100) + '%" style="--ths-bg:' + s.bg +
';--ths-line:' + s.line + ';--ths-flash:' + s.flash + '">' + esc(body) + '</span>';
});
html += '<span class="ths-caret"></span></span>';
if (d.legend !== 'false') html += '<span class="ths-key" aria-hidden="true">Low<i></i>High confidence</span>';
el.innerHTML = html;
el._ths = { toks: [].slice.call(el.querySelectorAll('.ths-tok')), caret: el.querySelector('.ths-caret'), run: 0, timers: [] };
// Tap a token to show its probability on touch screens.
el.addEventListener('pointerdown', function (e) {
var t = e.target.closest && e.target.closest('.ths-tok');
el._ths.toks.forEach(function (k) { if (k !== t) k.classList.remove('ths-tip'); });
if (t) t.classList.toggle('ths-tip');
});
}
function showAll(st) {
st.toks.forEach(function (t) { t.classList.add('ths-in'); t.style.animation = 'none'; });
st.caret.style.display = 'none';
}
function play(el) {
var st = el._ths, d = el.dataset;
var rate = +d.rate || DEFAULTS.rate;
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.toks.forEach(function (t) { t.classList.remove('ths-in', 'ths-tip'); t.style.animation = ''; });
// The caret sits after the last token shown.
var text = st.caret.parentNode;
st.caret.style.display = '';
text.insertBefore(st.caret, st.toks[0]);
anime.utils.set(text, { opacity: 1 });
var i = 0;
function tick() {
if (i >= st.toks.length) {
st.caret.style.display = 'none';
if (d.loop !== undefined) later(function () {
anime.animate(text, { opacity: [1, 0], duration: 400, ease: 'in(2)',
onComplete: function () { if (run === st.run) later(function () { play(el); }, 200); } });
}, hold);
return;
}
var t = st.toks[i++];
t.classList.add('ths-in');
text.insertBefore(st.caret, t.nextSibling);
// Models hesitate a little on tokens they're unsure of.
var p = parseInt(t.dataset.pct, 10) / 100;
var gap = 1000 / rate * (0.6 + Math.random() * 0.6 + (1 - p) * 1.4);
later(tick, gap);
}
later(tick, 350);
}
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-token-heat-stream]').forEach(function (el) {
if (el._ths) return;
setup(el);
if (reduced()) showAll(el._ths);
else io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.tokenHeatStream = function (el) { if (el && el._ths) play(el); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><p data-token-heat-stream>
The capital of Australia is <span data-p="0.41">Canberra</span>.
</p>