Message Send Flow
A message types into a chat’s input box and the send button lights up. On send, the text lifts out of the box as a bubble and glides up into the conversation, nudging older messages up, with “Delivered” and then “Read” beneath it. Add a reply and the other person types back.
Are you nearly here?
Just parking
Great, we’re by the window
How to use it
- Paste the snippet just before
</head>. - Add
data-message-send-flowto a container holding the conversation so far, one element per message withdata-from="them"ordata-from="me", and put what to send indata-message.
Options
Add any of these attributes to the same element.
data-message | Messages to type and send, in turn, separated by |. |
data-reply | A reply that arrives, after typing dots, once the last message is read. |
data-placeholder | Text in the empty input. Default “Message”. |
data-speed | Typing time per character. Default 55 ms. |
data-loop | Clear and send again after a pause. |
data-hold | How long the finished chat stays before looping. Default 2400 ms. |
Give the element a height in CSS (default 20em); older messages scroll off the top as new ones arrive.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Message Send Flow: a message types into a chat's input box, the send
// button is pressed, and the message lifts out of the box as a bubble and
// slides up into the conversation, nudging older messages up. A
// "Delivered" then "Read" note follows.
// Usage:
// <div data-message-send-flow data-message="On my way!|See you in five">
// <p data-from="them">Are you nearly here?</p>
// </div>
// Children are the conversation so far (data-from="them" or "me").
// Optional: data-message (messages to send in turn, separated by |),
// data-placeholder (empty input text, default "Message"), data-speed (ms
// per character, default 55), data-reply (a reply that arrives after the
// last message), data-loop (clear and send again), data-hold (ms before
// looping, default 2400).
var DEFAULTS = { message: 'On my way!', placeholder: 'Message', speed: 55, hold: 2400 };
var css =
'.msx{display:flex;flex-direction:column;height:20em;text-align:left;background:#fff;border-radius:16px;border:1px solid rgba(0,0,0,.08);' +
'box-shadow:0 20px 44px -16px rgba(40,10,70,.3);overflow:hidden}' +
'.msx-log{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:flex-end;gap:0.45em;padding:0.9em 0.9em 0.5em}' +
'.msx-b{max-width:78%;padding:0.5em 0.85em;border-radius:1.1em;line-height:1.35;margin:0;overflow-wrap:anywhere}' +
'.msx-them{align-self:flex-start;background:#eceef2;color:#111;border-bottom-left-radius:0.35em}' +
'.msx-me{align-self:flex-end;background:var(--accent,#6a0dad);color:#fff;border-bottom-right-radius:0.35em}' +
'.msx-slot{align-self:flex-end;max-width:78%;display:flex;justify-content:flex-end}' +
'.msx-slot .msx-b{max-width:none}' +
'.msx-note{align-self:flex-end;font-size:0.75em;color:#8a8f98;margin:-0.2em 0.2em 0;min-height:1.2em}' +
'.msx-dots{display:inline-flex;gap:0.25em;align-items:center;padding:0.75em 0.9em}' +
'.msx-dots i{width:0.4em;height:0.4em;border-radius:50%;background:#555;opacity:.45;display:block}' +
'.msx-bar{display:flex;align-items:center;gap:0.55em;padding:0.55em 0.7em 0.7em;border-top:1px solid #eceef1}' +
'.msx-in{flex:1;min-width:0;display:flex;align-items:center;height:2.3em;padding:0 0.9em;border-radius:99px;background:#f3f4f6;white-space:nowrap;overflow:hidden}' +
'.msx-txt{overflow:hidden;text-overflow:ellipsis}' +
'.msx-ph{color:#9ca3af}' +
'.msx-send{flex:none;display:grid;place-items:center;width:2.3em;height:2.3em;border-radius:50%;background:#d4d4d8;color:#fff;transition:background-color .2s}' +
'.msx-send.msx-ready{background:var(--accent,#6a0dad)}' +
'.msx-send svg{width:1.05em;height:1.05em}' +
'.msx-caret{display:inline-block;flex:none;width:2px;height:1.15em;margin-left:1px;background:var(--accent,#6a0dad)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var ARROW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function opt(d, k) { return d[k] !== undefined && d[k] !== '' ? d[k] : DEFAULTS[k]; }
function bubble(text, who) {
var p = document.createElement('p');
p.className = 'msx-b msx-' + who;
p.textContent = text;
return p;
}
function setup(el) {
var d = el.dataset;
var history = Array.from(el.children).map(function (c) {
return { text: c.textContent.trim(), who: c.getAttribute('data-from') === 'me' ? 'me' : 'them' };
});
var msgs = opt(d, 'message').split('|').map(function (m) { return m.trim(); }).filter(Boolean);
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'A chat: ' + history.concat(msgs.map(function (m) { return { text: m, who: 'me' }; }))
.map(function (m) { return (m.who === 'me' ? 'Me: ' : 'Them: ') + m.text; }).join(' '));
el.textContent = '';
el.classList.add('msx');
var log = document.createElement('div');
log.className = 'msx-log';
log.setAttribute('aria-hidden', 'true');
var bar = document.createElement('div');
bar.className = 'msx-bar';
bar.setAttribute('aria-hidden', 'true');
bar.innerHTML = '<span class="msx-in"><span class="msx-txt"></span></span><span class="msx-send">' + ARROW + '</span>';
el.appendChild(log);
el.appendChild(bar);
var caret = document.createElement('span');
caret.className = 'msx-caret';
return {
el: el, log: log, history: history, msgs: msgs, reply: d.reply || '', caret: caret,
input: bar.querySelector('.msx-in'), txt: bar.querySelector('.msx-txt'), send: bar.querySelector('.msx-send'),
visible: false, wake: null
};
}
function reset(s) {
s.log.textContent = '';
s.history.forEach(function (m) { s.log.appendChild(bubble(m.text, m.who)); });
s.note = document.createElement('p');
s.note.className = 'msx-note';
placeholder(s);
}
function placeholder(s) {
s.txt.className = 'msx-txt msx-ph';
s.txt.textContent = opt(s.el.dataset, 'placeholder');
s.send.classList.remove('msx-ready');
}
function finished(s) {
reset(s);
s.msgs.forEach(function (m) { s.log.appendChild(bubble(m, 'me')); });
if (s.reply) s.log.appendChild(bubble(s.reply, 'them'));
}
function whenVisible(s) {
return s.visible ? Promise.resolve() : new Promise(function (r) { s.wake = r; });
}
function type(s, text, speed) {
return new Promise(function (done) {
var n = 0;
s.txt.className = 'msx-txt';
s.txt.textContent = '';
s.input.appendChild(s.caret);
(function tick() {
s.txt.textContent = text.slice(0, n);
if (n === 1) s.send.classList.add('msx-ready');
if (n++ >= text.length) { done(); return; }
var ch = text.charAt(n - 1);
setTimeout(tick, speed * (0.5 + Math.random()) + (/[,.!?]/.test(ch) ? speed * 4 : 0));
})();
});
}
// Lift the message out of the input and fly it into the conversation.
function send(s, text) {
s.caret.remove();
anime.animate(s.send, { scale: [1, 0.85, 1], duration: 240, ease: 'inOut(2)' });
var from = s.txt.getBoundingClientRect();
placeholder(s);
if (s.note.parentNode) s.note.remove();
var slot = document.createElement('div');
slot.className = 'msx-slot';
var b = bubble(text, 'me');
slot.appendChild(b);
s.log.appendChild(slot);
var h = slot.offsetHeight;
var to = b.getBoundingClientRect();
var scale = s.el.getBoundingClientRect().width / (s.el.offsetWidth || 1) || 1;
var dx = (from.left - to.left) / scale + parseFloat(getComputedStyle(b).paddingLeft) * -1;
var dy = (from.top - to.top) / scale - parseFloat(getComputedStyle(b).paddingTop) + (from.height - parseFloat(getComputedStyle(b).lineHeight)) / 2 / scale;
// The slot grows from nothing so older messages glide up as the bubble lands.
anime.utils.set(slot, { height: 0 });
anime.animate(slot, { height: [0, h], duration: 420, ease: 'out(3)', onComplete: function () { slot.style.height = ''; } });
anime.animate(b, { x: [dx, 0], y: [dy - h, 0], duration: 520, ease: 'out(4)' });
anime.animate(b, { scale: [0.9, 1], duration: 520, ease: 'outBack(1.6)' });
return sleep(560).then(function () {
s.log.appendChild(s.note);
s.note.textContent = 'Delivered';
anime.animate(s.note, { opacity: [0, 1], duration: 250 });
});
}
function trim(s) {
// Drop messages that have scrolled out of the top so the log stays light.
while (s.log.children.length > 12) s.log.removeChild(s.log.firstChild);
}
function play(s) {
var d = s.el.dataset;
var speed = +opt(d, 'speed');
reset(s);
var chain = sleep(600);
s.msgs.forEach(function (m, i) {
chain = chain.then(function () { return type(s, m, speed); })
.then(function () { return sleep(380); })
.then(function () { return send(s, m); })
.then(function () { trim(s); return sleep(i === s.msgs.length - 1 ? 700 : 900); });
});
chain.then(function () {
s.note.textContent = 'Read';
anime.animate(s.note, { opacity: [0.3, 1], duration: 300 });
if (!s.reply) return;
return sleep(700).then(function () {
s.note.remove();
var dots = bubble('', 'them');
dots.classList.add('msx-dots');
dots.innerHTML = '<i></i><i></i><i></i>';
s.log.appendChild(dots);
anime.animate(dots, { scale: [0.6, 1], opacity: [0, 1], duration: 280, ease: 'outBack(2)' });
anime.animate(dots.children, { y: [0, -4, 0], opacity: [0.45, 1, 0.45], duration: 600, delay: anime.stagger(140), loop: 3 });
return sleep(1700).then(function () {
var r = bubble(s.reply, 'them');
s.log.replaceChild(r, dots);
anime.animate(r, { scale: [0.7, 1], opacity: [0, 1], duration: 380, ease: 'outBack(1.8)' });
r.style.transformOrigin = '0 100%';
});
});
}).then(function () {
if (d.loop === undefined || d.loop === 'false') return;
return sleep(+opt(d, 'hold')).then(function () { return whenVisible(s); }).then(function () {
anime.animate(s.log, { opacity: [1, 0], duration: 300, ease: 'in(2)' });
return sleep(320);
}).then(function () {
reset(s);
anime.animate(s.log, { opacity: [0, 1], duration: 300, ease: 'out(2)' });
return sleep(150);
}).then(function () { play(s); });
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._msx;
s.visible = e.isIntersecting;
if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-message-send-flow]').forEach(function (el) {
if (el._msx) return;
var s = (el._msx = setup(el));
if (reduced()) { finished(s); placeholder(s); return; }
reset(s);
io.observe(el);
whenVisible(s).then(function () { play(s); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-message-send-flow data-message="On my way!" data-loop>
<p data-from="them">Are you nearly here?</p>
</div>