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
- Paste the snippet just before
</head>. - Add
data-pop-on-captionsto an element holding the whole transcript. - 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-words | Most words in one caption. Default 4. |
data-rate | Time on screen per word. Default 330 ms. |
data-variant | box (default): white text on a black box, or shadow: bold outlined text with no box. |
data-speaker | A name shown in front of the first caption, such as “Chef”. |
data-loop | Start 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.
<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><p data-pop-on-captions data-speaker="Chef">
Welcome back to the kitchen. Today we are making fresh pasta.
</p>