Teleprompter Scroll
A script rolls up steadily through a dark window, like a teleprompter. The line passing the reading marks in the middle lights up while the others dim, and the top and bottom fade away. Tap it to pause.
Good evening, and welcome.
Tonight: a year of motion on the web.
Pages that type, count and bloom.
Buttons that answer when you press them.
And text that knows when to keep still.
Small touches, done with care,
make a site feel alive.
Thank you for watching.
How to use it
- Paste the snippet just before
</head>. - Add
data-teleprompter-scrollto a container with one element per line, and give it a height in CSS.
Options
Add any of these attributes to the same element.
data-speed | Scroll speed in pixels per second. Default 26. |
data-loop | Carry on from the top after the last line, with no jump. |
data-mirror | Flip it left to right, as a real teleprompter’s glass does. |
data-markers="false" | Hide the reading marks and guide line. |
A tap, click, Space or Enter pauses and resumes it. It only scrolls while on screen, and with reduced motion turned on it rests at the first line.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Teleprompter Scroll: a script scrolls up steadily through a window, like a
// teleprompter. The line passing the reading marks in the middle is
// highlighted, the rest are dimmed, and the edges fade away.
// Usage:
// <div data-teleprompter-scroll data-loop>
// <p>Good evening, and welcome.</p>
// <p>Tonight we look at the year in motion.</p>
// </div>
// One element per line. Give the element a height in CSS (default 14em).
// Optional: data-speed (pixels per second, default 26), data-loop (start
// again from the top after the last line), data-mirror (flip it left to
// right, as a real teleprompter's glass does), data-markers="false" (hide
// the reading marks).
// A tap or click pauses and resumes it; so do Space and Enter when focused.
var DEFAULTS = { speed: 26 };
var css =
'.tlp{position:relative;height:14em;overflow:hidden;text-align:left;background:#0d0d12;color:#f4f4f5;border-radius:14px;' +
'box-shadow:0 18px 40px rgba(10,10,20,.3);-webkit-user-select:none;user-select:none;touch-action:pan-y}' +
'.tlp:focus-visible{outline:3px solid var(--accent,#6a0dad);outline-offset:3px}' +
'.tlp-mirror .tlp-track{transform-origin:50% 50%}' +
'.tlp-view{position:absolute;inset:0;-webkit-mask-image:linear-gradient(transparent,#000 28%,#000 72%,transparent);mask-image:linear-gradient(transparent,#000 28%,#000 72%,transparent)}' +
'.tlp-track{position:absolute;left:0;right:0;top:0;padding:0 2.2em;will-change:transform}' +
'.tlp-track p{margin:0;padding:0.35em 0;font-weight:700;line-height:1.3;opacity:.32;transition:opacity .45s,color .45s}' +
'.tlp-track p.tlp-cur{opacity:1;color:#fff}' +
'.tlp-mark{position:absolute;top:50%;width:0;height:0;margin-top:-0.45em;border-style:solid;border-width:0.45em 0 0.45em 0.6em;' +
'border-color:transparent transparent transparent var(--accent-2,#c084fc);pointer-events:none}' +
'.tlp-mark-l{left:0.7em}' +
'.tlp-mark-r{right:0.7em;border-width:0.45em 0.6em 0.45em 0;border-color:transparent var(--accent-2,#c084fc) transparent transparent}' +
'.tlp-band{position:absolute;left:0;right:0;top:50%;height:0;border-top:1px dashed rgba(192,132,252,.25);pointer-events:none}' +
'.tlp-badge{position:absolute;top:0.6em;right:0.7em;padding:0.2em 0.6em;border-radius:99px;background:rgba(255,255,255,.12);' +
'font-size:0.6em;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:0;transition:opacity .25s;pointer-events:none}' +
'.tlp-paused .tlp-badge{opacity:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function opt(d, k) { return d[k] !== undefined && d[k] !== '' ? d[k] : DEFAULTS[k]; }
function setup(el) {
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var lines = Array.from(el.children);
el.classList.add('tlp');
if (d.mirror !== undefined && d.mirror !== 'false') el.classList.add('tlp-mirror');
var view = document.createElement('div');
view.className = 'tlp-view';
var track = document.createElement('div');
track.className = 'tlp-track';
lines.forEach(function (l) { track.appendChild(l); });
var clones = [];
if (loop) {
// A second copy follows the first so the loop is seamless.
lines.forEach(function (l) {
var c = l.cloneNode(true);
c.setAttribute('aria-hidden', 'true');
track.appendChild(c);
clones.push(c);
});
}
view.appendChild(track);
el.textContent = '';
el.appendChild(view);
if (d.markers !== 'false') {
el.insertAdjacentHTML('beforeend', '<span class="tlp-band" aria-hidden="true"></span><span class="tlp-mark tlp-mark-l" aria-hidden="true"></span><span class="tlp-mark tlp-mark-r" aria-hidden="true"></span>');
}
var thumb = !!el.closest('[aria-hidden="true"]') || !!el.closest('.anim-thumb');
if (!thumb) {
el.insertAdjacentHTML('beforeend', '<span class="tlp-badge" aria-hidden="true">Paused</span>');
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
if (!el.hasAttribute('role')) el.setAttribute('role', 'region');
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Teleprompter. Press to pause or resume.');
}
var s = {
el: el, d: d, loop: loop, track: track, lines: lines, all: lines.concat(clones), gap: 0,
pos: 0, start: 0, end: 0, period: 0, cur: null, visible: false, paused: false, raf: 0, last: 0
};
if (!thumb) {
el.addEventListener('click', function () { toggle(s); });
el.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle(s); }
});
}
return s;
}
function measure(s) {
var H = s.el.clientHeight;
var first = s.lines[0], last = s.lines[s.lines.length - 1];
if (!first) return;
var mid = function (l) { return l.offsetTop + l.offsetHeight / 2; };
var was = s.start ? (s.pos - s.start) / ((s.period || s.end - s.start) || 1) : 0;
if (s.loop) {
// Leave a blank stretch between the end and the restart.
var gap = Math.round(H * 0.45);
s.lines.forEach(function (l, i) { if (s.all[s.lines.length + i]) s.all[s.lines.length + i].style.marginTop = i === 0 ? gap + 'px' : ''; });
s.period = s.all[s.lines.length].offsetTop - first.offsetTop;
}
s.start = mid(first) - H / 2;
s.end = mid(last) - H / 2;
s.pos = s.start + was * ((s.period || s.end - s.start) || 0);
draw(s);
}
function draw(s) {
var H = s.el.clientHeight;
var flip = s.el.classList.contains('tlp-mirror') ? ' scaleX(-1)' : '';
s.track.style.transform = 'translateY(' + (-s.pos).toFixed(2) + 'px)' + flip;
// The current line is the one crossing the middle.
var centre = s.pos + H / 2, cur = null;
for (var i = 0; i < s.all.length; i++) {
var l = s.all[i];
if (centre >= l.offsetTop - 2 && centre <= l.offsetTop + l.offsetHeight + 2) { cur = l; break; }
}
if (cur !== s.cur) {
if (s.cur) s.cur.classList.remove('tlp-cur');
if (cur) cur.classList.add('tlp-cur');
s.cur = cur;
}
}
function frame(s, t) {
s.raf = 0;
if (!s.visible || s.paused) return;
var dt = s.last ? Math.min(64, t - s.last) : 16;
s.last = t;
s.pos += (+opt(s.d, 'speed')) * dt / 1000;
if (s.loop) {
if (s.pos >= s.start + s.period) s.pos -= s.period;
} else if (s.pos >= s.end) {
s.pos = s.end;
draw(s);
return;
}
draw(s);
s.raf = requestAnimationFrame(function (t2) { frame(s, t2); });
}
function run(s) {
if (s.raf || reduced()) return;
s.last = 0;
s.raf = requestAnimationFrame(function (t) { frame(s, t); });
}
function toggle(s) {
s.paused = !s.paused;
s.el.classList.toggle('tlp-paused', s.paused);
if (!s.paused) {
if (!s.loop && s.pos >= s.end) s.pos = s.start; // finished: start again from the top
run(s);
}
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._tlp;
s.visible = e.isIntersecting;
if (s.visible) run(s);
});
}, { threshold: 0.1 });
document.querySelectorAll('[data-teleprompter-scroll]').forEach(function (el) {
if (el._tlp) return;
var s = (el._tlp = setup(el));
measure(s);
if (window.ResizeObserver) new ResizeObserver(function () { measure(s); }).observe(el);
if (reduced()) {
// Still: the script sits at the start with the first line marked.
return;
}
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-teleprompter-scroll data-loop style="height: 14em">
<p>Good evening, and welcome.</p>
<p>Tonight: a year of motion on the web.</p>
<p>Thank you for watching.</p>
</div>