← Feedback

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

  1. Paste the snippet just before </head>.
  2. Add data-read-receipts to a message. Its text is the message; the time and ticks are added in the corner, and the bubble is styled for you.
  3. It plays sent, delivered and read when it scrolls into view. For real messages add data-manual and call animacious.readReceipts(el, "delivered") and (el, "read") as you hear back.

Options

Add any of these attributes to the same element.

data-timeTime shown in the corner. Default the current time.
data-stepGap between sent, delivered and read. Default 1000 ms.
data-colorColour of read ticks. Default #1d9bf0.
data-bgBubble colour. Default #d9fdd3.
data-statesent, delivered or read: just show that, without animating.
data-manualPop in as sent, then wait for script calls.
data-loopSend the message again after a pause.
data-holdHow 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.

1 · Snippet
<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>
2 · Your HTML
<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>