Scroll Typewriter
Text types itself out letter by letter behind a blinking caret as you scroll through it, and un-types if you scroll back up. Bold text and links inside keep their styling, and the lines never jump about as it types.
Scroll inside ↓
Every great story starts with a single line. Keep scrolling and this one writes itself, letter by letter, right before your eyes.
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-typewriterto a paragraph or heading.
Options
Add any of these attributes to the same element.
data-start | Where in the view the text’s top starts typing, from 0 (top) to 1 (bottom). Default 0.85. |
data-end | Where in the view the text’s bottom finishes typing. Default 0.45. |
data-speed | The most letters it types each frame while catching up with your scrolling. Default 2. |
data-caret="false" | No caret. |
data-color | The caret’s colour. Default the text colour. |
Screen readers get the whole text at once. With reduced motion the text is simply there. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll Typewriter: text types itself out, letter by letter behind a
// blinking caret, as you scroll through it. Scroll back and it un-types.
// Usage: <p data-scroll-typewriter>Text that types as you scroll.</p>
// (bold, links and other inline markup inside are kept)
// Optional: data-start (where in the view the text's top starts typing,
// 0 is the top and 1 the bottom, default 0.85), data-end (where in the view
// its bottom finishes typing, default 0.45), data-speed (the most letters
// typed per frame while catching up, default 2), data-caret="false" (no
// caret), data-color (caret colour, default the text colour).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { start: 0.85, end: 0.45, speed: 2 };
var css =
'.sctw-w{display:inline-block;white-space:nowrap}' +
'.sctw-c{visibility:hidden}' +
'.sctw-c.sctw-on{visibility:visible}' +
'.sctw-caret{display:inline-block;width:0;height:1.05em;margin-right:-.12em;border-right:.12em solid currentColor;vertical-align:-.15em;animation:sctw-blink 1s steps(1) infinite}' +
'.sctw-busy .sctw-caret{animation:none}' +
'@keyframes sctw-blink{50%{opacity:0}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if (o === 'auto' || o === 'scroll') return p;
}
return null;
}
// Wrap every letter in a span (words kept whole so lines wrap as before).
function split(el) {
var chars = [];
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
var nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(function (node) {
var text = node.nodeValue.replace(/\s+/g, ' ');
if (!text) return;
var frag = document.createDocumentFragment();
text.split(/( )/).forEach(function (part) {
if (!part) return;
if (part === ' ') {
var sp = document.createElement('span');
sp.className = 'sctw-c';
sp.textContent = ' ';
sp.setAttribute('aria-hidden', 'true');
frag.appendChild(sp);
chars.push(sp);
return;
}
var w = document.createElement('span');
w.className = 'sctw-w';
w.setAttribute('aria-hidden', 'true');
for (var i = 0; i < part.length; i++) {
var c = document.createElement('span');
c.className = 'sctw-c';
c.textContent = part[i];
w.appendChild(c);
chars.push(c);
}
frag.appendChild(w);
});
node.parentNode.replaceChild(frag, node);
});
return chars;
}
function setup(el) {
var d = el.dataset;
var start = num(d.start, DEFAULTS.start);
var end = num(d.end, DEFAULTS.end);
var speed = Math.max(1, num(d.speed, DEFAULTS.speed));
var box = scroller(el);
var text = el.textContent.replace(/\s+/g, ' ').trim();
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', text);
var chars = split(el);
if (!chars.length) return;
var caret = null;
if (d.caret !== 'false') {
caret = document.createElement('span');
caret.className = 'sctw-caret';
caret.setAttribute('aria-hidden', 'true');
if (d.color) caret.style.borderColor = d.color;
}
var shown = 0, goal = 0, running = false, idle = null;
function place() {
if (!caret) return;
var before = shown > 0 ? chars[shown - 1] : null;
if (before) before.parentNode.insertBefore(caret, before.nextSibling);
else chars[0].parentNode.insertBefore(caret, chars[0]);
}
function readScroll() {
var top = box ? box.getBoundingClientRect().top + box.clientTop : 0;
var viewH = box ? box.clientHeight : window.innerHeight;
var r = el.getBoundingClientRect();
var a = r.top - top - viewH * start; // > 0: not started
var b = r.bottom - top - viewH * end; // <= 0: finished
var p = a >= 0 ? 0 : b <= 0 ? 1 : -a / (b - a);
goal = Math.round(Math.max(0, Math.min(1, p)) * chars.length);
}
function tick() {
if (shown === goal) {
running = false;
clearTimeout(idle);
idle = setTimeout(function () { el.classList.remove('sctw-busy'); }, 400);
return;
}
el.classList.add('sctw-busy');
var diff = goal - shown;
var step = Math.min(Math.abs(diff), speed);
var i;
if (diff > 0) for (i = 0; i < step; i++) chars[shown++].classList.add('sctw-on');
else for (i = 0; i < step; i++) chars[--shown].classList.remove('sctw-on');
place();
requestAnimationFrame(tick);
}
function onScroll() {
readScroll();
if (!running && goal !== shown) {
running = true;
requestAnimationFrame(tick);
}
}
place();
onScroll();
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
}
function init() {
if (reduced()) return; // no motion: the text is simply there
document.querySelectorAll('[data-scroll-typewriter]').forEach(function (el) {
if (el._sctw) return;
el._sctw = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><p data-scroll-typewriter>
Every great story starts with a <b>single line</b>.
</p>