← Broadcast

Pop-on Captions

Subtitles in the style of broadcast closed captions. A few words at a time pop on together in a black caption box, stay long enough to read, then the next group replaces them. Captions end early at the end of a sentence, so they read naturally.

Welcome back to the kitchen. Today we are making fresh pasta, and it is much easier than you think. All you need is flour, eggs and a little patience.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pop-on-captions to an element holding the whole transcript.
  3. Place it over your video yourself (for example absolutely, near the bottom) and size it with font-size.

Options

Add any of these attributes to the same element.

data-wordsMost words in one caption. Default 4.
data-rateTime on screen per word. Default 330 ms.
data-variantbox (default): white text on a black box, or shadow: bold outlined text with no box.
data-speakerA name shown in front of the first caption, such as “Chef”.
data-loopStart again from the top after the last caption.

Screen readers get the whole transcript at once. With reduced motion it is shown in full, without popping.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pop-on Captions: subtitles that pop on a few words at a time, like
    // broadcast closed captions. Each group of words appears all at once in a
    // caption box with a tiny pop, stays long enough to read, then the next
    // group replaces it. Groups break early at the end of a sentence.
    // Usage: <p data-pop-on-captions>Welcome back to the show. Today we are cooking pasta.</p>
    // Size it with font-size and place it over your video yourself.
    // Optional: data-words (most words per caption, default 4), data-rate (ms
    // on screen per word, default 330), data-variant ("box", the default: white
    // text on a black box, or "shadow": bold white text with an outline and no
    // box), data-speaker (a name shown in front of the first caption, e.g.
    // "CHEF"), data-loop (start again from the top after the last caption).
    var DEFAULTS = { words: 4, rate: 330 };

    var css =
      '.poc{display:block;text-align:center;line-height:1.25}' +
      '.poc-line{display:inline-block;max-width:100%;padding:0.18em 0.5em 0.22em;border-radius:0.18em;background:rgba(0,0,0,.82);color:#fff;font-weight:600;letter-spacing:0.01em;' +
        'box-decoration-break:clone;-webkit-box-decoration-break:clone;transform-origin:50% 100%}' +
      '.poc-shadow .poc-line{background:none;font-weight:800;text-shadow:0 0 0.12em #000,0.06em 0.06em 0 #000,-0.06em 0.06em 0 #000,0.06em -0.06em 0 #000,-0.06em -0.06em 0 #000}' +
      '.poc-who{color:#facc15;margin-right:0.35em}' +
      '.poc-empty{visibility:hidden}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(el, k) { var v = el.dataset[k]; return v !== undefined && v !== '' ? +v : DEFAULTS[k]; }

    // Split the text into captions of up to `max` words, breaking after . ! ? and
    // preferring a break after a comma when a caption is nearly full.
    function chunk(text, max) {
      var words = text.split(/\s+/).filter(Boolean), out = [], cur = [];
      words.forEach(function (w) {
        cur.push(w);
        if (cur.length >= max || /[.!?]["”)]?$/.test(w) || (cur.length >= max - 1 && /[,;:]$/.test(w))) {
          out.push(cur);
          cur = [];
        }
      });
      if (cur.length) out.push(cur);
      return out;
    }

    function setup(el) {
      var d = el.dataset;
      var text = el.textContent.trim().replace(/\s+/g, ' ');
      el.classList.add('poc');
      if (d.variant === 'shadow') el.classList.add('poc-shadow');
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var line = document.createElement('span');
      line.className = 'poc-line';
      line.setAttribute('aria-hidden', 'true');
      el.appendChild(line);
      el._poc = { line: line, caps: chunk(text, Math.max(1, num(el, 'words'))), i: 0, speaker: d.speaker || '' };
      if (reduced()) { line.textContent = text; return; }
      show(el, -1);
    }

    function show(el, i) {
      var s = el._poc;
      s.line.textContent = '';
      s.line.classList.toggle('poc-empty', i < 0);
      if (i < 0) { s.line.textContent = ' '; return; }
      if (i === 0 && s.speaker) {
        var who = document.createElement('span');
        who.className = 'poc-who';
        who.textContent = s.speaker + ':';
        s.line.appendChild(who);
      }
      s.line.appendChild(document.createTextNode(s.caps[i].join(' ')));
      // Pop on: real captions just appear; a tiny scale makes the change easy to see.
      anime.animate(s.line, { scale: [0.94, 1], opacity: [0.4, 1], duration: 140, ease: 'out(2)' });
    }

    function step(el) {
      var s = el._poc;
      clearTimeout(s.timer);
      if (!s.visible) return;
      if (s.i >= s.caps.length) {
        if (el.dataset.loop === undefined || el.dataset.loop === 'false') return;
        // A short blank before starting again.
        show(el, -1);
        s.i = 0;
        s.timer = setTimeout(function () { step(el); }, 900);
        return;
      }
      show(el, s.i);
      var n = s.caps[s.i].length;
      s.i++;
      s.timer = setTimeout(function () { step(el); }, Math.max(700, n * num(el, 'rate')) + (s.i === 1 && s.speaker ? 300 : 0));
    }

    function init() {
      // Captions pause off screen and carry on from where they were.
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._poc;
          var was = s.visible;
          s.visible = e.isIntersecting;
          if (e.isIntersecting && !was) step(e.target);
          if (!e.isIntersecting) clearTimeout(s.timer);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-pop-on-captions]').forEach(function (el) {
        if (el._poc) return;
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-pop-on-captions data-speaker="Chef">
  Welcome back to the kitchen. Today we are making fresh pasta.
</p>