Spray Paint Reveal
A spray can sweeps back and forth across the text. Paint lands solid only inside the letters, as if sprayed through a stencil, and leaves a soft speckled overspray around them.
Fresh paint
How to use it
- Paste the snippet just before
</head>. - Add
data-spray-paint-revealto a short, one-line heading.
Options
Add any of these attributes to the same element.
data-color | The paint. Default the text’s own colour. |
data-passes | How many sweeps across the text. Default 2. |
data-overspray | How strong the mist around the letters is, from 0 (none) to 1. Default 0.5. |
data-duration | Time for all the sweeps. Default 1600 ms. |
data-loop | Fade it off and spray it again. |
data-hold | How long it stays, when looping. Default 1800 ms. |
data-delay | Wait before starting. Default 0 ms. |
The paint is drawn on a canvas over the real text, which stays in the page (invisibly) for screen readers and copying. Keep it to one line: the text doesn’t wrap.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spray Paint Reveal: a spray can sweeps across the text, and the paint
// only lands solid inside the letters, as if sprayed through a stencil,
// with a soft speckled overspray left around them.
// Usage: <h1 data-spray-paint-reveal>Fresh paint</h1>
// Keep it to one line: the text does not wrap.
// Optional: data-color (the paint, default the text colour), data-passes
// (sweeps across the text, default 2), data-overspray (how strong the
// mist around the letters is, 0 to 1, default 0.5), data-duration (ms for
// all the sweeps, default 1600), data-loop (fade it off and spray again),
// data-hold (ms to stay, when looping, default 1800), data-delay (ms).
var DEFAULTS = { passes: 2, overspray: 0.5, duration: 1600, hold: 1800, delay: 0 };
var css =
'.sprv{position:relative;display:inline-block;white-space:nowrap}' +
'.sprv-txt{color:transparent}' +
'.sprv-cv{position:absolute;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function layer() { return document.createElement('canvas'); }
// A rough bell curve, so most droplets land near the nozzle.
function gauss() { return (Math.random() + Math.random() + Math.random() - 1.5) / 1.5; }
function setup(el) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.textContent = '';
el.classList.add('sprv');
// The real text stays (invisible) for layout, selection and screen readers.
var txt = document.createElement('span');
txt.className = 'sprv-txt';
txt.textContent = text;
el.appendChild(txt);
var cv = layer();
cv.className = 'sprv-cv';
cv.setAttribute('aria-hidden', 'true');
el.appendChild(cv);
var s = { el: el, txt: txt, cv: cv, ctx: cv.getContext('2d'), mask: layer(), ink: layer(), tmp: layer(), text: text, t: 0, done: false };
size(s);
return s;
}
// Size the canvases to the text plus a margin for overspray, and draw the
// stencil: the text itself, in the element's own font.
function size(s) {
var el = s.el, cs = getComputedStyle(el);
var fs = parseFloat(cs.fontSize);
var pad = Math.round(fs * 0.3);
var w = s.txt.offsetWidth + pad * 2, h = s.txt.offsetHeight + pad * 2;
var dpr = Math.min(2, window.devicePixelRatio || 1);
s.cv.style.left = s.cv.style.top = -pad + 'px';
s.cv.style.width = w + 'px';
s.cv.style.height = h + 'px';
[s.cv, s.mask, s.ink, s.tmp].forEach(function (c) {
c.width = Math.round(w * dpr);
c.height = Math.round(h * dpr);
c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
});
s.w = w; s.h = h; s.pad = pad; s.fs = fs;
s.color = s.el.dataset.color || cs.color;
var m = s.mask.getContext('2d');
m.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + fs + 'px ' + cs.fontFamily;
if ('letterSpacing' in m) m.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
m.textBaseline = 'alphabetic';
m.fillStyle = '#000';
var mt = m.measureText(s.text);
var a = mt.fontBoundingBoxAscent || fs * 0.9, d = mt.fontBoundingBoxDescent || fs * 0.25;
m.fillText(s.text, pad, pad + (s.txt.offsetHeight - a - d) / 2 + a);
}
// Spray droplets around the nozzle at (x, y) onto the ink layer.
function spray(s, x, y, r, n) {
var c = s.ink.getContext('2d');
c.fillStyle = s.color;
for (var i = 0; i < n; i++) {
var dx = gauss() * r, dy = gauss() * r;
var dot = Math.random() * Math.random() * s.fs * 0.035 + 0.5;
c.globalAlpha = 0.25 + Math.random() * 0.6;
c.beginPath();
c.arc(x + dx, y + dy, dot, 0, 6.2832);
c.fill();
}
c.globalAlpha = 1;
}
// Show the ink: faint everywhere (overspray), full strength through the
// stencil. dry (0 to 1) lays a solid coat over the letters at the end.
function render(s, dry) {
var ctx = s.ctx, t = s.tmp.getContext('2d'), w = s.w, h = s.h;
var mist = +s.el.dataset.overspray;
if (isNaN(mist)) mist = DEFAULTS.overspray;
t.globalCompositeOperation = 'source-over';
t.clearRect(0, 0, w, h);
t.drawImage(s.mask, 0, 0, w, h);
t.globalCompositeOperation = 'source-in';
t.drawImage(s.ink, 0, 0, w, h);
if (dry > 0) {
t.globalAlpha = dry;
t.fillStyle = s.color;
t.fillRect(0, 0, w, h);
t.globalAlpha = 1;
}
ctx.clearRect(0, 0, w, h);
ctx.globalAlpha = 0.28 * mist;
ctx.drawImage(s.ink, 0, 0, w, h);
ctx.globalAlpha = 1;
ctx.drawImage(s.tmp, 0, 0, w, h);
}
function finish(s) {
s.done = true;
render(s, 1);
}
function play(el) {
var s = el._sprv, d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var passes = Math.max(1, Math.round(+d.passes || DEFAULTS.passes));
s.done = false;
s.ink.getContext('2d').clearRect(0, 0, s.w, s.h);
s.cv.style.opacity = 1;
var start = null, last = 0;
function frame(now) {
if (!s.visible) { s.resume = frame; start = null; return; }
if (start === null) start = now - last * duration;
var p = Math.min(1, (now - start) / duration);
// Each pass sweeps the full width, alternating direction, on its own
// band of the text's height, with a wobbly hand.
var steps = Math.max(1, Math.ceil((p - last) * 60));
var band = (s.h - s.pad * 2) / passes;
var r = band * 0.5 + s.fs * 0.06;
for (var k = 1; k <= steps; k++) {
var q = last + (p - last) * k / steps;
var pass = Math.min(passes - 1, Math.floor(q * passes));
var u = q * passes - pass;
if (pass % 2) u = 1 - u;
var x = s.pad * 0.3 + u * (s.w - s.pad * 0.6);
var y = s.pad + band * (pass + 0.5) + Math.sin(q * 40) * band * 0.18;
spray(s, x, y, r, Math.round(s.fs * 1.6));
}
last = p;
render(s, 0);
if (p < 1) { requestAnimationFrame(frame); return; }
// The paint dries to a solid coat inside the stencil.
var o = { dry: 0 };
anime.animate(o, {
dry: 1,
duration: 450,
ease: 'out(2)',
onUpdate: function () { render(s, o.dry); },
onComplete: function () {
finish(s);
if (!loop) return;
setTimeout(function () {
anime.animate(s.cv, {
opacity: 0,
duration: 500,
ease: 'inQuad',
onComplete: function () { setTimeout(function () { play(el); }, 300); }
});
}, +d.hold || DEFAULTS.hold);
}
});
}
requestAnimationFrame(frame);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._sprv;
s.visible = e.isIntersecting;
if (!s.visible) return;
if (!s.started) {
s.started = true;
setTimeout(function () { play(el); }, +el.dataset.delay || DEFAULTS.delay);
} else if (s.resume) {
var f = s.resume;
s.resume = null;
requestAnimationFrame(f);
}
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._sprv;
if (!s) return;
size(s);
// A finished stencil is redrawn crisp; one mid-spray starts afresh.
if (s.done || reduced()) finish(s);
});
});
var run = function () {
document.querySelectorAll('[data-spray-paint-reveal]').forEach(function (el) {
if (el._sprv) return;
var s = el._sprv = setup(el);
ro.observe(el);
if (reduced()) { finish(s); return; }
io.observe(el);
});
};
// The stencil is drawn in the element's font, so wait for web fonts.
if (document.fonts && document.fonts.ready) document.fonts.ready.then(run);
else run();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-spray-paint-reveal>Fresh paint</h1>
<!-- or, pink, three passes, with heavy overspray: -->
<h1 data-spray-paint-reveal data-color="#f472b6" data-passes="3" data-overspray="1">Fresh paint</h1>