Chat Highlight Card
Put a viewer’s message up on screen. Chat scrolls up the side as messages arrive, then one lights up and a big copy of it lifts out of the chat and grows into a card in the middle of the screen, with the viewer’s initial and colour. It holds for a moment and slips away.
that jump was unreal
lol the cat just walked past
Best stream all week, thank you!
what's the song?
GG
how long have you been playing this?
This community is the best
How to use it
- Paste the snippet just before
</head>. - Add
data-chat-highlight-cardto a sized box over your video. Put the messages inside as<p>s, each with its sender indata-user. - Mark the ones to pull out with
data-highlight, or callanimacious.chatHighlightCard(el, 'name', 'message')from your chat bot.
Options
Add any of these attributes to the same element.
data-user (on a message) | Who sent it. Each name gets its own colour. |
data-color (on a message) | That sender’s colour instead. |
data-highlight (on a message) | Pull this one out. With none marked, every third message is. |
data-rate | Time between messages. Default 1100 ms. |
data-hold | How long the card stays up. Default 2800 ms. |
data-side | left puts the chat on the left; the card appears in the space beside it. |
data-autoplay="false" | Don’t play through the messages by itself; only show what you send with the script call. |
The messages play through over and over while it is on screen. The chat is a live region (role="log"), so screen readers hear new messages.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Chat Highlight Card: a live chat column where messages arrive one by one.
// Every so often a message lights up, then a big copy of it lifts out of the
// chat and grows into a card in the middle of the screen, holds, and slips
// away again.
// Usage: <div data-chat-highlight-card style="height: 360px">
// <p data-user="pixel_panda">That jump was unreal</p>
// <p data-user="nova" data-highlight>Best stream all week!</p>
// </div>
// Each child is one message, with its sender in data-user (and, if you like,
// data-color). Messages marked data-highlight are the ones pulled out; with
// none marked, every third one is. The element needs a size: the chat runs
// down its right-hand side and the card appears in the space to the left.
// Optional: data-rate (ms between messages, default 1100), data-hold (ms the
// card stays up, default 2800), data-side ("left" puts the chat on the left),
// data-autoplay="false" (no messages by themselves; use the script call).
// Script use: animacious.chatHighlightCard(el, 'name', 'message') adds a
// message and pulls it out.
var DEFAULTS = { rate: 1100, hold: 2800 };
var COLORS = ['#f472b6', '#38bdf8', '#4ade80', '#facc15', '#c084fc', '#fb923c', '#2dd4bf'];
var css =
'.chlc{position:relative;overflow:hidden;line-height:1.3;color:#fff;container-type:inline-size}' +
'.chlc-chat{position:absolute;top:0;bottom:0;right:0;width:clamp(9em,38%,20em);overflow:hidden;font-size:clamp(9px,2.6cqw,14px);' +
'background:linear-gradient(90deg,rgba(2,6,23,0),rgba(2,6,23,.55) 18%);' +
'-webkit-mask-image:linear-gradient(transparent,#000 22%);mask-image:linear-gradient(transparent,#000 22%)}' +
'.chlc-left .chlc-chat{right:auto;left:0;background:linear-gradient(270deg,rgba(2,6,23,0),rgba(2,6,23,.55) 18%)}' +
'.chlc-list{position:absolute;left:0;right:0;bottom:0;padding:0.6em 0.8em 0.8em 1.4em}' +
'.chlc-left .chlc-list{padding:0.6em 1.4em 0.8em 0.8em}' +
'.chlc-msg{position:relative;margin:0.3em 0 0;padding:0.3em 0.5em;border-radius:0.45em;overflow-wrap:anywhere}' +
'.chlc-msg b{font-weight:800;margin-right:0.35em}' +
'.chlc-msg.chlc-lit{background:color-mix(in srgb,var(--chlc-c) 28%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--chlc-c) 70%,transparent)}' +
'.chlc-card{position:absolute;z-index:3;display:flex;gap:0.8em;align-items:flex-start;padding:0.9em 1.2em 1em 0.9em;border-radius:1em;transform-origin:0 0;' +
'font-size:clamp(11px,3.6cqw,22px);max-width:min(22em,54%);' +
'background:linear-gradient(140deg,rgba(30,27,75,.97),rgba(15,23,42,.97));border:1px solid rgba(255,255,255,.16);' +
'box-shadow:0 1em 3em rgba(0,0,0,.5),0 0 0 0.12em color-mix(in srgb,var(--chlc-c) 70%,transparent),0 0 2.4em color-mix(in srgb,var(--chlc-c) 40%,transparent)}' +
'.chlc-av{flex:none;display:grid;place-items:center;width:2.2em;height:2.2em;border-radius:50%;font-weight:900;color:#0f172a;background:var(--chlc-c)}' +
'.chlc-body{display:flex;flex-direction:column;gap:0.25em;min-width:0}' +
'.chlc-name{font-size:0.72em;font-weight:800;letter-spacing:0.06em;color:var(--chlc-c)}' +
'.chlc-text{font-size:1.15em;font-weight:800;line-height:1.25;overflow-wrap:anywhere}' +
'.chlc-q{position:absolute;right:0.35em;top:-0.15em;font:900 2.6em/1 Georgia,serif;color:var(--chlc-c);opacity:.35}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(el, k) { var v = el.dataset[k]; return v !== undefined && v !== '' ? +v : DEFAULTS[k]; }
function colorFor(name) {
var h = 0;
for (var i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return COLORS[h % COLORS.length];
}
function setup(el) {
var d = el.dataset;
var src = [].slice.call(el.children);
if (!src.length) return false;
var anyMarked = src.some(function (p) { return p.dataset.highlight !== undefined; });
var msgs = src.map(function (p, i) {
var user = p.dataset.user || 'viewer';
return {
user: user, text: p.textContent.trim(), color: p.dataset.color || colorFor(user),
pick: anyMarked ? p.dataset.highlight !== undefined : i % 3 === 2
};
});
el.classList.add('chlc');
if (d.side === 'left') el.classList.add('chlc-left');
el.innerHTML = '<div class="chlc-chat" role="log" aria-label="Chat"><div class="chlc-list"></div></div>';
el._chlc = {
msgs: msgs, next: 0, vis: false,
chat: el.querySelector('.chlc-chat'), list: el.querySelector('.chlc-list')
};
if (reduced()) {
// A still: the chat filled in, the first picked message shown as a card.
msgs.forEach(function (m) { addMsg(el, m, true); });
var first = msgs.filter(function (m) { return m.pick; })[0];
if (first) showCard(el, first, null, true);
}
return true;
}
function addMsg(el, m, still) {
var s = el._chlc;
var p = document.createElement('p');
p.className = 'chlc-msg';
p.style.setProperty('--chlc-c', m.color);
p.innerHTML = '<b></b><span></span>';
p.querySelector('b').textContent = m.user;
p.querySelector('b').style.color = m.color;
p.querySelector('span').textContent = m.text;
s.list.appendChild(p);
// keep the log short
while (s.list.children.length > 14) s.list.removeChild(s.list.firstChild);
if (!still) {
var h = p.offsetHeight + parseFloat(getComputedStyle(p).marginTop);
anime.animate(s.list, { y: [h, 0], duration: 360, ease: 'out(3)' });
anime.animate(p, { opacity: [0, 1], x: ['1em', '0em'], duration: 360, ease: 'out(3)' });
}
return p;
}
function showCard(el, m, from, still) {
var s = el._chlc;
var card = document.createElement('div');
card.className = 'chlc-card';
card.setAttribute('aria-hidden', 'true');
card.style.setProperty('--chlc-c', m.color);
card.innerHTML = '<span class="chlc-av"></span><span class="chlc-body"><span class="chlc-name"></span><span class="chlc-text"></span></span><span class="chlc-q">”</span>';
card.querySelector('.chlc-av').textContent = m.user.charAt(0).toUpperCase();
card.querySelector('.chlc-name').textContent = m.user;
card.querySelector('.chlc-text').textContent = m.text;
el.appendChild(card);
// Centre the card in the space beside the chat.
var W = el.clientWidth, H = el.clientHeight;
var cw = s.chat.offsetWidth;
var left = el.classList.contains('chlc-left');
var areaL = left ? cw : 0, areaW = W - cw;
var x = areaL + (areaW - card.offsetWidth) / 2;
var y = (H - card.offsetHeight) / 2;
card.style.left = Math.max(8, x) + 'px';
card.style.top = Math.max(8, y) + 'px';
if (still || !from) return card;
// FLIP: start the card over the chat message, grow it into place.
var fr = from.getBoundingClientRect(), cr = card.getBoundingClientRect();
var sc = fr.width / cr.width;
var tl = anime.createTimeline();
tl.add(card, {
x: [fr.left - cr.left, 0], y: [fr.top - cr.top, 0], scale: [sc, 1], opacity: [{ from: 0, to: 1, duration: 120 }],
duration: 720, ease: 'inOut(3)'
}, 0)
.add(card.querySelector('.chlc-av'), { scale: [0, 1], rotate: [-90, 0], duration: 500, ease: 'outBack(2.4)' }, 520)
.add(card.querySelector('.chlc-q'), { opacity: [0, 0.35], y: ['-0.5em', '0em'], duration: 500, ease: 'out(3)' }, 600)
.add(card, { rotate: [0, -1.2], duration: 500, ease: 'out(2)' }, 700)
.add(card, { opacity: 0, y: '-1em', scale: 0.92, rotate: -1.2, duration: 380, ease: 'in(2)' }, 720 + num(el, 'hold'));
tl.then(function () { if (card.parentNode) card.parentNode.removeChild(card); });
return card;
}
function pullOut(el, p, m) {
p.classList.add('chlc-lit');
anime.animate(p, { scale: [1, 1.05, 1], duration: 500, ease: 'inOut(2)' });
setTimeout(function () { showCard(el, m, p); }, 450);
setTimeout(function () { p.classList.remove('chlc-lit'); }, 2400);
}
function tick(el) {
var s = el._chlc;
clearTimeout(s.wait);
if (!s.vis) { s.pending = true; return; }
var m = s.msgs[s.next];
s.next = (s.next + 1) % s.msgs.length;
var p = addMsg(el, m);
var delay = num(el, 'rate');
if (m.pick) {
pullOut(el, p, m);
delay += num(el, 'hold') + 900;
}
s.wait = setTimeout(function () { tick(el); }, delay);
}
window.animacious = window.animacious || {};
window.animacious.chatHighlightCard = function (el, user, text) {
if (!el || !el._chlc) return;
var m = { user: user || 'viewer', text: text || '', color: colorFor(user || 'viewer'), pick: true };
var p = addMsg(el, m, reduced());
if (reduced()) return;
pullOut(el, p, m);
};
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._chlc;
s.vis = e.isIntersecting;
if (!s.vis || el.dataset.autoplay === 'false') return;
if (!s.started) {
s.started = true;
// Start with a few messages already in the chat.
for (var i = 0; i < 3 && !s.msgs[s.next].pick; i++) {
addMsg(el, s.msgs[s.next], true);
s.next = (s.next + 1) % s.msgs.length;
}
s.wait = setTimeout(function () { tick(el); }, 500);
} else if (s.pending) { s.pending = false; tick(el); }
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-chat-highlight-card]').forEach(function (el) {
if (el._chlc) return;
if (!setup(el)) return;
if (!reduced()) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-chat-highlight-card style="height: 360px">
<p data-user="pixel_panda">that jump was unreal</p>
<p data-user="nova_streams" data-highlight>Best stream all week!</p>
<p data-user="mira_v">what's the song?</p>
</div>