← AI

Streaming Text

Words appear a token at a time, each one fading up out of a soft blur, just like a chatbot writing its answer. Tokens arrive in little uneven bursts, with a breath after each sentence, and a glowing dot rides along the end of the text.

Why is the sky blue?

Sunlight holds every colour of the rainbow. On its way through the air, tiny gas molecules scatter the short blue waves far more than the long red ones, so blue light reaches your eyes from every part of the sky.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-streaming-text to a paragraph (or any element holding text). Bold, links and other inline markup inside it are kept.
  3. The text keeps its full space while it streams, so nothing below it jumps around.

Options

Add any of these attributes to the same element.

data-loopFade out after a pause and stream the answer again.
data-speedRoughly how many tokens arrive each second. Default 22.
data-fadeHow long each token takes to fade in. Default 450 ms.
data-delayWait before the first token. Default 300 ms.
data-holdHow long the finished text shows before looping. Default 2600 ms.
data-cursor="false"Hide the glowing dot at the end.

Call animacious.streamingText(el) to stream an element again, for example after you’ve put a new answer in it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Streaming Text: words appear token by token with a soft fade, the way a
    // chatbot streams its answer. Tokens arrive at an uneven pace (little bursts
    // and pauses, slower after full stops), each fading up out of a blur, with a
    // glowing dot riding along the end. Bold, links and other inline markup inside
    // the element are kept.
    // Usage: <p data-streaming-text>Your answer text…</p>
    // Optional: data-loop (fade out and stream again), data-speed (tokens per
    // second, default 22), data-fade (ms each token takes to fade in, default
    // 450), data-delay (ms before it starts, default 300), data-hold (ms it
    // stays finished before looping, default 2600), data-cursor="false" (no dot).
    // Script use: animacious.streamingText(el) restarts the stream.
    var DEFAULTS = { speed: 22, fade: 450, delay: 300, hold: 2600 };

    var css =
      '.stx-tok{opacity:0}' +
      '.stx-done .stx-tok{opacity:1}' +
      '.stx-cur{display:inline-block;position:relative;width:0;height:1em;vertical-align:-.12em}' +
      '.stx-cur::after{content:"";position:absolute;left:.18em;top:50%;width:.55em;height:.55em;margin-top:-.275em;' +
      'border-radius:50%;background:currentColor;opacity:.85;animation:stx-pulse 1s ease-in-out infinite}' +
      '.stx-done .stx-cur{display:none}' +
      '@keyframes stx-pulse{50%{transform:scale(.6);opacity:.4}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Split a run of text into token-sized pieces: words, with longer words cut
    // into chunks of 3 to 5 letters, and the following space kept on each piece.
    function tokenize(text) {
      var out = [];
      var parts = text.match(/\s+|[^\s]+/g) || [];
      parts.forEach(function (p) {
        if (/^\s+$/.test(p)) {
          if (out.length) out[out.length - 1] += p;
          else out.push(p);
          return;
        }
        if (p.length <= 6) { out.push(p); return; }
        var i = 0;
        while (i < p.length) {
          var n = 3 + Math.floor(Math.random() * 3);
          if (p.length - (i + n) < 3) n = p.length - i;
          out.push(p.slice(i, i + n));
          i += n;
        }
      });
      return out;
    }

    function setup(el) {
      var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
      var nodes = [], n;
      while ((n = walker.nextNode())) if (n.nodeValue.trim()) nodes.push(n);
      var toks = [];
      nodes.forEach(function (node) {
        var frag = document.createDocumentFragment();
        tokenize(node.nodeValue).forEach(function (t) {
          var s = document.createElement('span');
          s.className = 'stx-tok';
          s.textContent = t;
          frag.appendChild(s);
          toks.push(s);
        });
        node.parentNode.replaceChild(frag, node);
      });
      var cur = document.createElement('span');
      cur.className = 'stx-cur';
      cur.setAttribute('aria-hidden', 'true');
      el._stx = { toks: toks, cur: cur, timer: null, run: 0 };
      if (el.dataset.cursor === 'false') cur.style.display = 'none';
      if (reduced()) el.classList.add('stx-done');
    }

    function play(el) {
      var st = el._stx, d = el.dataset;
      var speed = +d.speed || DEFAULTS.speed;
      var fade = d.fade !== undefined ? +d.fade : DEFAULTS.fade;
      var delay = d.delay !== undefined ? +d.delay : DEFAULTS.delay;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var run = ++st.run;
      clearTimeout(st.timer);
      el.classList.remove('stx-done');
      anime.utils.set(el, { opacity: 1 });
      st.toks.forEach(function (t) { anime.utils.set(t, { opacity: 0, filter: 'blur(4px)' }); });
      var first = st.toks[0];
      if (first) first.parentNode.insertBefore(st.cur, first);
      var i = 0;

      function next() {
        if (run !== st.run) return;
        // A short burst of 1 to 3 tokens at a time, like chunks off the wire.
        var burst = 1 + Math.floor(Math.random() * 3);
        var last = '';
        for (var k = 0; k < burst && i < st.toks.length; k++, i++) {
          var t = st.toks[i];
          anime.animate(t, { opacity: [0, 1], filter: ['blur(4px)', 'blur(0px)'], duration: fade, ease: 'out(2)' });
          t.parentNode.insertBefore(st.cur, t.nextSibling);
          last = t.textContent;
        }
        if (i >= st.toks.length) {
          st.timer = setTimeout(function () {
            if (run !== st.run) return;
            el.classList.add('stx-done');
            if (d.loop === undefined) return;
            st.timer = setTimeout(function () {
              if (run !== st.run) return;
              anime.animate(el, {
                opacity: 0, duration: 500, ease: 'in(2)',
                onComplete: function () { if (run === st.run) play(el); }
              });
            }, hold);
          }, fade);
          return;
        }
        var gap = (1000 / speed) * burst * (0.5 + Math.random());
        if (/[.!?:]\s*$/.test(last)) gap += 260;      // a breath after a sentence
        else if (/,\s*$/.test(last)) gap += 90;
        st.timer = setTimeout(next, gap);
      }
      st.timer = setTimeout(next, delay);
    }

    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-streaming-text]').forEach(function (el) {
        if (el._stx) return;
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.streamingText = function (el) {
      if (!el._stx) setup(el);
      if (!reduced()) play(el);
    };

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-streaming-text>Sunlight holds every colour of the rainbow. Air scatters the short <strong>blue</strong> waves far more than the red ones.</p>

<!-- looping, a little faster: -->
<p data-streaming-text data-loop data-speed="30">…</p>