← Scroll

Scroll Typewriter

Text types itself out letter by letter behind a blinking caret as you scroll through it, and un-types if you scroll back up. Bold text and links inside keep their styling, and the lines never jump about as it types.

Scroll inside ↓

Every great story starts with a single line. Keep scrolling and this one writes itself, letter by letter, right before your eyes.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scroll-typewriter to a paragraph or heading.

Options

Add any of these attributes to the same element.

data-startWhere in the view the text’s top starts typing, from 0 (top) to 1 (bottom). Default 0.85.
data-endWhere in the view the text’s bottom finishes typing. Default 0.45.
data-speedThe most letters it types each frame while catching up with your scrolling. Default 2.
data-caret="false"No caret.
data-colorThe caret’s colour. Default the text colour.

Screen readers get the whole text at once. With reduced motion the text is simply there. Works inside scrolling boxes too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Scroll Typewriter: text types itself out, letter by letter behind a
    // blinking caret, as you scroll through it. Scroll back and it un-types.
    // Usage: <p data-scroll-typewriter>Text that types as you scroll.</p>
    //        (bold, links and other inline markup inside are kept)
    // Optional: data-start (where in the view the text's top starts typing,
    // 0 is the top and 1 the bottom, default 0.85), data-end (where in the view
    // its bottom finishes typing, default 0.45), data-speed (the most letters
    // typed per frame while catching up, default 2), data-caret="false" (no
    // caret), data-color (caret colour, default the text colour).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { start: 0.85, end: 0.45, speed: 2 };

    var css =
      '.sctw-w{display:inline-block;white-space:nowrap}' +
      '.sctw-c{visibility:hidden}' +
      '.sctw-c.sctw-on{visibility:visible}' +
      '.sctw-caret{display:inline-block;width:0;height:1.05em;margin-right:-.12em;border-right:.12em solid currentColor;vertical-align:-.15em;animation:sctw-blink 1s steps(1) infinite}' +
      '.sctw-busy .sctw-caret{animation:none}' +
      '@keyframes sctw-blink{50%{opacity:0}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    // Wrap every letter in a span (words kept whole so lines wrap as before).
    function split(el) {
      var chars = [];
      var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
      var nodes = [];
      while (walker.nextNode()) nodes.push(walker.currentNode);
      nodes.forEach(function (node) {
        var text = node.nodeValue.replace(/\s+/g, ' ');
        if (!text) return;
        var frag = document.createDocumentFragment();
        text.split(/( )/).forEach(function (part) {
          if (!part) return;
          if (part === ' ') {
            var sp = document.createElement('span');
            sp.className = 'sctw-c';
            sp.textContent = ' ';
            sp.setAttribute('aria-hidden', 'true');
            frag.appendChild(sp);
            chars.push(sp);
            return;
          }
          var w = document.createElement('span');
          w.className = 'sctw-w';
          w.setAttribute('aria-hidden', 'true');
          for (var i = 0; i < part.length; i++) {
            var c = document.createElement('span');
            c.className = 'sctw-c';
            c.textContent = part[i];
            w.appendChild(c);
            chars.push(c);
          }
          frag.appendChild(w);
        });
        node.parentNode.replaceChild(frag, node);
      });
      return chars;
    }

    function setup(el) {
      var d = el.dataset;
      var start = num(d.start, DEFAULTS.start);
      var end = num(d.end, DEFAULTS.end);
      var speed = Math.max(1, num(d.speed, DEFAULTS.speed));
      var box = scroller(el);
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', text);
      var chars = split(el);
      if (!chars.length) return;

      var caret = null;
      if (d.caret !== 'false') {
        caret = document.createElement('span');
        caret.className = 'sctw-caret';
        caret.setAttribute('aria-hidden', 'true');
        if (d.color) caret.style.borderColor = d.color;
      }

      var shown = 0, goal = 0, running = false, idle = null;

      function place() {
        if (!caret) return;
        var before = shown > 0 ? chars[shown - 1] : null;
        if (before) before.parentNode.insertBefore(caret, before.nextSibling);
        else chars[0].parentNode.insertBefore(caret, chars[0]);
      }

      function readScroll() {
        var top = box ? box.getBoundingClientRect().top + box.clientTop : 0;
        var viewH = box ? box.clientHeight : window.innerHeight;
        var r = el.getBoundingClientRect();
        var a = r.top - top - viewH * start;      // > 0: not started
        var b = r.bottom - top - viewH * end;     // <= 0: finished
        var p = a >= 0 ? 0 : b <= 0 ? 1 : -a / (b - a);
        goal = Math.round(Math.max(0, Math.min(1, p)) * chars.length);
      }

      function tick() {
        if (shown === goal) {
          running = false;
          clearTimeout(idle);
          idle = setTimeout(function () { el.classList.remove('sctw-busy'); }, 400);
          return;
        }
        el.classList.add('sctw-busy');
        var diff = goal - shown;
        var step = Math.min(Math.abs(diff), speed);
        var i;
        if (diff > 0) for (i = 0; i < step; i++) chars[shown++].classList.add('sctw-on');
        else for (i = 0; i < step; i++) chars[--shown].classList.remove('sctw-on');
        place();
        requestAnimationFrame(tick);
      }

      function onScroll() {
        readScroll();
        if (!running && goal !== shown) {
          running = true;
          requestAnimationFrame(tick);
        }
      }

      place();
      onScroll();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onScroll);
    }

    function init() {
      if (reduced()) return;   // no motion: the text is simply there
      document.querySelectorAll('[data-scroll-typewriter]').forEach(function (el) {
        if (el._sctw) return;
        el._sctw = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-scroll-typewriter>
  Every great story starts with a <b>single line</b>.
</p>