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
- Paste the snippet just before
</head>. - Add
data-bouncy-captionsto an element holding the words, and wrap the key words in<mark>(or<strong>or<em>). - Size it with
font-size; captions read best big.
Options
Add any of these attributes to the same element.
data-words | Most words on screen at once. A screen also ends after a comma or full stop. Default 3. |
data-rate | Gap between words. Default 360 ms. |
data-color | Key word colour. Default #facc15. |
data-loop | Start again from the top after the last screen. |
<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><p data-bouncy-captions>
This one trick made my videos <mark>ten times</mark> better.
</p>