ASCII Reveal
The text builds up out of a grid of characters: first a scatter of dots, then denser and denser ASCII, until the shapes are solid enough to turn into the real letters.
Rendering
How to use it
- Paste the snippet just before
</head>. - Add
data-ascii-revealto a heading or short line of text.
Options
Add any of these attributes to the same element.
data-color | The colour of the ASCII characters. Default the text colour. |
data-cell | Size of a grid cell, as a fraction of the font size. Default 0.06. |
data-chars | The characters, from sparse to dense, starting with a space. Default .:-=+*#%@. |
data-duration | Time to build up. Default 1600 ms. |
data-loop | Build up, hold, break back down and build up again. |
data-hold | How long the text stays solid, when looping. Default 2000 ms. |
data-delay | Wait before starting. Default 0 ms. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// ASCII Reveal: the text builds up out of a grid of characters, from sparse
// dots through denser and denser ASCII to solid letters.
// Usage: <h1 data-ascii-reveal>Rendering</h1>
// Optional: data-color (the ASCII characters, default the text colour),
// data-cell (grid cell size as a fraction of the font size, default 0.06),
// data-chars (the ramp from sparse to dense, default " .:-=+*#%@"),
// data-duration (ms to build up, default 1600), data-loop (break back down
// and build up again), data-hold (ms to stay solid, when looping),
// data-delay (ms before starting).
var DEFAULTS = { cell: 0.06, chars: ' .:-=+*#%@', duration: 1600, hold: 2000, delay: 0 };
var css =
'.ascr-on{position:relative}' +
'.ascr-txt{transition:opacity .45s ease}' +
'.ascr-w{display:inline-block;white-space:nowrap}' +
'.ascr-cv{position:absolute;pointer-events:none;transition:opacity .45s ease}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setup(el) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
el.classList.add('ascr-on');
var txt = document.createElement('span');
txt.className = 'ascr-txt';
txt.setAttribute('aria-hidden', 'true');
var words = text.split(' ').map(function (word, i) {
if (i) txt.appendChild(document.createTextNode(' '));
var w = document.createElement('span');
w.className = 'ascr-w';
w.textContent = word;
txt.appendChild(w);
return w;
});
el.appendChild(txt);
var cv = document.createElement('canvas');
cv.className = 'ascr-cv';
cv.setAttribute('aria-hidden', 'true');
el.appendChild(cv);
return { txt: txt, words: words, cv: cv, cells: [] };
}
// Draws the words into an offscreen canvas and samples it into grid cells,
// each with how much of it is covered by ink.
function measure(el) {
var s = el._ascr, d = el.dataset, cs = getComputedStyle(el);
var fs = parseFloat(cs.fontSize);
var size = Math.max(3, fs * (+d.cell || DEFAULTS.cell));
var cw = size, chH = size * 1.25;
var er = el.getBoundingClientRect();
var pad = Math.ceil(size);
var W = Math.ceil(er.width) + pad * 2, H = Math.ceil(er.height) + pad * 2;
var m = document.createElement('canvas');
m.width = W; m.height = H;
var mx = m.getContext('2d', { willReadFrequently: true });
mx.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
if ('letterSpacing' in mx) mx.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
var tm = mx.measureText('Hg');
var asc = tm.fontBoundingBoxAscent || fs * 0.8, desc = tm.fontBoundingBoxDescent || fs * 0.2;
mx.fillStyle = '#000';
s.words.forEach(function (w) {
var r = w.getBoundingClientRect();
mx.fillText(w.textContent, r.left - er.left + pad, r.top - er.top + pad + (r.height - asc - desc) / 2 + asc);
});
var data = mx.getImageData(0, 0, W, H).data;
var cells = [];
for (var y = 0; y + chH <= H; y += chH) {
for (var x = 0; x + cw <= W; x += cw) {
var hit = 0, n = 0;
for (var sy = 0.2; sy < 1; sy += 0.3) {
for (var sx = 0.2; sx < 1; sx += 0.3) {
var px = (x + sx * cw) | 0, py = (y + sy * chH) | 0;
hit += data[(py * W + px) * 4 + 3] / 255;
n++;
}
}
var cov = hit / n;
if (cov < 0.06) continue;
cells.push({ x: x + cw / 2, y: y + chH / 2, cov: cov, t: Math.random() * 0.3 + (x / W) * 0.35 });
}
}
var dpr = Math.min(2, window.devicePixelRatio || 1);
var cv = s.cv;
cv.style.left = -pad + 'px';
cv.style.top = -pad + 'px';
cv.style.width = W + 'px';
cv.style.height = H + 'px';
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = '600 ' + (chH * 1.0).toFixed(1) + 'px ui-monospace, Menlo, Consolas, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = d.color || cs.color;
s.ctx = ctx; s.W = W; s.H = H; s.cells = cells;
}
// p runs 0 (empty) to 1 (every cell at its densest character).
function draw(el, p) {
var s = el._ascr, ramp = el.dataset.chars || DEFAULTS.chars, top = ramp.length - 1;
s.ctx.clearRect(0, 0, s.W, s.H);
s.cells.forEach(function (c) {
var level = Math.max(0, Math.min(1, (p * 1.05 - c.t) / 0.35)) * Math.sqrt(c.cov);
var i = Math.round(level * top);
if (level < 1 && i > 0 && Math.random() < 0.12) i += Math.random() < 0.5 ? -1 : 1;
i = Math.max(0, Math.min(top, i));
if (i === 0 || ramp[i] === ' ') return;
s.ctx.fillText(ramp[i], c.x, c.y);
});
}
function run(el, from, to, duration, done) {
var start = performance.now();
(function tick(now) {
var q = Math.min(1, (now - start) / duration);
draw(el, from + (to - from) * q);
if (q < 1) requestAnimationFrame(tick);
else if (done) done();
})(start);
}
function play(el) {
var s = el._ascr, d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
measure(el);
s.cv.style.opacity = 1;
run(el, 0, 1, duration, function () {
// The densest grid holds a beat, then gives way to the real letters.
setTimeout(function () {
s.txt.style.opacity = 1;
s.cv.style.opacity = 0;
}, 200);
if (!loop) return;
setTimeout(function () {
measure(el);
draw(el, 1);
s.cv.style.opacity = 1;
s.txt.style.opacity = 0;
setTimeout(function () {
run(el, 1, 0, duration * 0.6, function () { setTimeout(function () { play(el); }, 400); });
}, 300);
}, +d.hold || DEFAULTS.hold);
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
var el = e.target;
setTimeout(function () {
(document.fonts ? document.fonts.ready : Promise.resolve()).then(function () { play(el); });
}, +el.dataset.delay || DEFAULTS.delay);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-ascii-reveal]').forEach(function (el) {
if (el._ascr) return;
el._ascr = setup(el);
if (reduced()) return;
el._ascr.txt.style.opacity = 0;
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-ascii-reveal>Rendering</h1>
<!-- or, with a coarser grid and a shorter ramp: -->
<h1 data-ascii-reveal data-cell="0.1" data-chars=" .oO@">Rendering</h1>