Read Receipts
A chat bubble pops up and its little ticks tell the story: one grey tick when the message is sent, a second slides in beside it when it’s delivered, and both turn blue with a hop when it’s been read.
Table for four at eight?
Perfect, see you there
Are you nearly here?
Two minutes away!
How to use it
- Paste the snippet just before
</head>. - Add
data-read-receiptsto a message. Its text is the message; the time and ticks are added in the corner, and the bubble is styled for you. - It plays sent, delivered and read when it scrolls into view. For real messages add
data-manualand callanimacious.readReceipts(el, "delivered")and(el, "read")as you hear back.
Options
Add any of these attributes to the same element.
data-time | Time shown in the corner. Default the current time. |
data-step | Gap between sent, delivered and read. Default 1000 ms. |
data-color | Colour of read ticks. Default #1d9bf0. |
data-bg | Bubble colour. Default #d9fdd3. |
data-state | sent, delivered or read: just show that, without animating. |
data-manual | Pop in as sent, then wait for script calls. |
data-loop | Send the message again after a pause. |
data-hold | How long it shows as read before looping. Default 2400 ms. |
Screen readers hear “Sent”, “Delivered” or “Read” after the time. With reduced motion the bubble shows as read.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Read Receipts: a chat bubble pops in and its little ticks tell the story.
// One grey tick when it's sent, a second slides in when it's delivered,
// and both turn blue with a hop when it's been read.
// Usage: <div data-read-receipts>On my way!</div> (the text is the message)
// Optional: data-time (the time in the corner, default the current time),
// data-step (ms between sent, delivered and read, default 1000),
// data-color (colour of read ticks, default #1d9bf0), data-bg (bubble
// colour, default #d9fdd3), data-state="sent|delivered|read" (just show
// that state, no animation), data-manual (show it sent, then wait for
// script calls), data-loop (send it again after a pause), data-hold (ms it
// shows as read before looping, default 2400).
// Script use: animacious.readReceipts(el, 'delivered') or (el, 'read').
var DEFAULTS = { step: 1000, color: '#1d9bf0', bg: '#d9fdd3', hold: 2400 };
var STATES = ['', 'sent', 'delivered', 'read'];
var WORDS = { sent: 'Sent', delivered: 'Delivered', read: 'Read' };
var css =
'.rrc{position:relative;display:block;width:fit-content;max-width:min(80%,26em);margin-left:auto;padding:.5em .7em .45em .8em;' +
'border-radius:1em 1em .3em 1em;background:var(--rrc-bg);color:#111b21;line-height:1.35;text-align:left;box-shadow:0 1px 1.5px rgba(0,0,0,.13);transform-origin:100% 100%}' +
'.rrc-meta{float:right;display:inline-flex;align-items:center;gap:.3em;margin:.45em -.15em -.1em .9em;font-size:.72em;color:rgba(17,27,33,.55);line-height:1}' +
'.rrc-meta svg{width:1.55em;height:1em;overflow:visible}' +
'.rrc-meta path{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}' +
'.rrc-ticks{color:rgba(17,27,33,.5);transform-origin:50% 60%}' +
'.rrc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function opt(el, k) { var v = el.dataset[k]; return v === undefined ? DEFAULTS[k] : v; }
function now() { var d = new Date(); return d.getHours() + ':' + ('0' + d.getMinutes()).slice(-2); }
function setup(el) {
var d = el.dataset;
el.classList.add('rrc');
el.style.setProperty('--rrc-bg', opt(el, 'bg'));
var meta = document.createElement('span');
meta.className = 'rrc-meta';
meta.innerHTML = '<span aria-hidden="true"></span>' +
'<svg class="rrc-ticks" viewBox="0 0 17 11" aria-hidden="true"><path class="rrc-a" d="M1.2 6.2l3 3L10.7 2"/><path class="rrc-b" d="M6.6 8.3l.9.9L14.8 2"/></svg>' +
'<span class="rrc-sr"></span>';
meta.firstChild.textContent = d.time || now();
el.appendChild(meta);
el._rrc = {
ticks: meta.querySelector('svg'), sr: meta.querySelector('.rrc-sr'),
a: anime.svg.createDrawable(meta.querySelector('.rrc-a')),
b: anime.svg.createDrawable(meta.querySelector('.rrc-b')),
state: '', timer: null, visible: false, waiting: false
};
var fixed = STATES.indexOf(d.state) > 0 ? d.state : (reduced() && d.manual === undefined ? 'read' : '');
set(el, fixed || '', true);
if (fixed) el._rrc.fixed = true;
}
function set(el, state, instant) {
var s = el._rrc;
var i = STATES.indexOf(state), was = STATES.indexOf(s.state);
if (i < 0) return;
s.state = state;
s.sr.textContent = WORDS[state] || '';
var blue = state === 'read' ? opt(el, 'color') : 'rgba(17,27,33,.5)';
if (instant || reduced()) {
anime.utils.set(s.a, { draw: i >= 1 ? '0 1' : '0 0' });
anime.utils.set(s.b, { draw: i >= 2 ? '0 1' : '0 0' });
s.ticks.style.color = blue;
return;
}
if (i >= 1 && was < 1) anime.animate(s.a, { draw: ['0 0', '0 1'], duration: 320, ease: 'out(3)' });
if (i >= 2 && was < 2) anime.animate(s.b, { draw: ['0 0', '0 1'], duration: 320, delay: was < 1 ? 200 : 0, ease: 'out(3)' });
if (i === 3 && was < 3) {
anime.animate(s.ticks, { color: blue, duration: 300, delay: 120, ease: 'out(2)' });
anime.animate(s.ticks, {
y: [{ to: '-.25em', duration: 160, ease: 'out(2)' }, { to: '0em', duration: 520, ease: 'outBounce' }],
scale: [{ to: 1.25, duration: 160, ease: 'out(2)' }, { to: 1, duration: 520, ease: 'out(3)' }],
delay: 120
});
} else if (i < 3) {
s.ticks.style.color = blue;
}
}
function play(el) {
var s = el._rrc, d = el.dataset;
clearTimeout(s.timer);
if (!s.visible) { s.waiting = true; return; }
s.waiting = false;
var step = +opt(el, 'step');
set(el, '', true);
anime.utils.set(s.ticks, { y: '0em', scale: 1 });
anime.animate(el, { opacity: [0, 1], scale: [0.6, 1], y: ['1em', '0em'], duration: 600, ease: 'outBack(1.6)' });
s.timer = setTimeout(function () {
set(el, 'sent');
if (d.manual !== undefined) return;
s.timer = setTimeout(function () {
set(el, 'delivered');
s.timer = setTimeout(function () {
set(el, 'read');
if (d.loop === undefined || d.loop === 'false') return;
s.timer = setTimeout(function () {
anime.animate(el, { opacity: 0, y: '-.6em', duration: 380, ease: 'in(2)', onComplete: function () { s.timer = setTimeout(function () { play(el); }, 400); } });
}, +opt(el, 'hold'));
}, step * 1.2);
}, step);
}, 450);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._rrc;
s.visible = e.isIntersecting;
if (!s.visible) return;
if (!s.started) { s.started = true; play(e.target); }
else if (s.waiting) play(e.target);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-read-receipts]').forEach(function (el) {
if (el._rrc) return;
setup(el);
if (reduced() || el._rrc.fixed) return;
anime.utils.set(el, { opacity: 0 });
io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.readReceipts = function (el, state) {
if (!el._rrc) return;
clearTimeout(el._rrc.timer);
set(el, state);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-read-receipts data-time="18:42">Two minutes away!</div>
<!-- driven by your chat: -->
<div id="msg" data-read-receipts data-manual>On my way</div>
<script>
socket.on("read", () => animacious.readReceipts(document.getElementById("msg"), "read"));
</script>