← Broadcast

Bouncy Captions

Captions in the style of short-form video. Big, bold, outlined words pop in one at a time with a springy bounce, a few to a screen. Mark the key words and they arrive bigger, tilted and in colour.

This one trick made my videos ten times better. You need to try it!

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-bouncy-captions to an element holding the words, and wrap the key words in <mark> (or <strong> or <em>).
  3. Size it with font-size; captions read best big.

Options

Add any of these attributes to the same element.

data-wordsMost words on screen at once. A screen also ends after a comma or full stop. Default 3.
data-rateGap between words. Default 360 ms.
data-colorKey word colour. Default #facc15.
data-loopStart again from the top after the last screen.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Bouncy Captions: short-video style captions. Big bold words pop in one at
    // a time with a springy bounce, a few to a screen, and the key words you
    // mark come in bigger, tilted and coloured.
    // Usage: <p data-bouncy-captions>This is <mark>so</mark> easy to use</p>
    // Wrap key words in <mark>, <strong> or <em>. Size it with font-size.
    // Optional: data-words (most words on screen at once, default 3), data-rate
    // (ms between words, default 360), data-color (key word colour, default
    // #facc15), data-loop (start again from the top after the last screen).
    var DEFAULTS = { words: 3, rate: 360, color: '#facc15' };

    var css =
      '.bcp{display:block;text-align:center;line-height:1.05;--bcp-c:' + DEFAULTS.color + '}' +
      '.bcp-page{display:flex;flex-wrap:wrap;justify-content:center;align-items:baseline;gap:0.08em 0.3em;min-height:1.1em}' +
      '.bcp-w{display:inline-block;color:#fff;font-weight:900;text-transform:uppercase;letter-spacing:-0.01em;' +
        '-webkit-text-stroke:0.07em #000;paint-order:stroke fill;text-shadow:0 0.08em 0 #000,0 0.14em 0.25em rgba(0,0,0,.45);transform-origin:50% 80%}' +
      '.bcp-key{color:var(--bcp-c);font-size:1.18em}';
    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]; }

    // Words in order, each flagged as key or not.
    function parse(el) {
      var out = [];
      el.childNodes.forEach(function (n) {
        var key = n.nodeType === 1 && /^(MARK|STRONG|EM|B)$/.test(n.tagName);
        (n.textContent || '').split(/\s+/).filter(Boolean).forEach(function (w) { out.push({ text: w, key: key }); });
      });
      return out;
    }

    // Group into screens of up to `max` words, ending a screen after . ! ? or ,
    function pages(words, max) {
      var out = [], cur = [];
      words.forEach(function (w) {
        cur.push(w);
        if (cur.length >= max || /[.!?,]$/.test(w.text)) { out.push(cur); cur = []; }
      });
      if (cur.length) out.push(cur);
      return out;
    }

    function setup(el) {
      var d = el.dataset;
      var words = parse(el);
      el.classList.add('bcp');
      el.style.setProperty('--bcp-c', d.color || DEFAULTS.color);
      el.setAttribute('aria-label', el.textContent.trim().replace(/\s+/g, ' '));
      el.textContent = '';
      var page = document.createElement('span');
      page.className = 'bcp-page';
      page.setAttribute('aria-hidden', 'true');
      el.appendChild(page);
      el._bcp = { page: page, pages: pages(words, Math.max(1, num(el, 'words'))), p: 0, w: 0 };
      if (reduced()) {
        words.forEach(function (w) { page.appendChild(word(w)); });
      }
    }

    function word(w) {
      var sp = document.createElement('span');
      sp.className = 'bcp-w' + (w.key ? ' bcp-key' : '');
      sp.textContent = w.text;
      return sp;
    }

    function step(el) {
      var s = el._bcp;
      clearTimeout(s.timer);
      if (!s.visible) return;
      var rate = num(el, 'rate');
      if (s.p >= s.pages.length) {
        if (el.dataset.loop === undefined || el.dataset.loop === 'false') return;
        s.p = 0; s.w = 0;
      }
      var pg = s.pages[s.p];
      if (s.w === 0) s.page.textContent = '';
      var w = pg[s.w];
      var sp = word(w);
      s.page.appendChild(sp);
      var tilt = w.key ? anime.utils.random(-7, 7) : 0;
      anime.utils.set(sp, { opacity: 0, scale: 0.2 });
      anime.animate(sp, {
        scale: [0.2, 1],
        y: ['0.35em', '0em'],
        rotate: [tilt * 2, tilt],
        opacity: { to: 1, duration: 100, ease: 'linear' },
        duration: w.key ? 650 : 480,
        ease: w.key ? 'outElastic(1, .5)' : 'outBack(2.6)'
      });
      s.w++;
      var wait = rate + (w.key ? 120 : 0);
      if (s.w >= pg.length) {
        // Hold the full screen a little, then clear it with a quick shrink.
        s.p++; s.w = 0;
        var last = s.p >= s.pages.length;
        wait = rate * 2.2 + (last ? 700 : 0);
        s.timer = setTimeout(function () {
          if (last && (el.dataset.loop === undefined || el.dataset.loop === 'false')) return;
          anime.animate(s.page.children, { scale: 0.85, opacity: 0, duration: 140, ease: 'in(2)' }).then(function () { step(el); });
        }, wait);
        return;
      }
      s.timer = setTimeout(function () { step(el); }, wait);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-bouncy-captions>
  This one trick made my videos <mark>ten times</mark> better.
</p>