Ink Spread Reveal
Text bleeds in like ink soaking into paper. Each letter lands as a soft, swollen blot and draws in to a crisp edge, spreading out from one spot, while the edges stay feathery until the ink dries.
Written in ink
How to use it
- Paste the snippet just before
</head>. - Add
data-ink-spread-revealto any heading or text element.
Options
Add any of these attributes to the same element.
data-from | Where the ink lands first: center (default), first, last or a letter number. |
data-loop | Soak in, hold, fade and soak in again. |
data-hold | How long the text stays before fading, when looping. Default 1800 ms. |
data-duration | How long each letter takes to soak in. Default 1500 ms. |
data-stagger | Gap between letters. Default 45 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 () {
// Ink Spread Reveal: text bleeds in like ink soaking into paper. Each letter
// starts as a soft, swollen blot and draws in to a crisp edge, spreading out
// from one point, while the edges stay feathered and fibrous until it dries.
// Usage: <h1 data-ink-spread-reveal>Written in ink</h1>
// Optional: data-from (where the ink lands first: "center", the default,
// "first", "last" or a letter number), data-loop (fade, then soak in again),
// data-hold (ms to stay before fading), data-duration (ms per letter),
// data-stagger (ms between letters), data-delay (ms before starting).
var DEFAULTS = { from: 'center', duration: 1500, stagger: 45, hold: 1800, delay: 0 };
var css =
'.isr-word{display:inline-block;white-space:nowrap}' +
'.isr-char{display:inline-block;will-change:transform,filter,opacity}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var count = 0;
var NS = 'http://www.w3.org/2000/svg';
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function split(el) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var chars = [];
text.split(' ').forEach(function (word, i) {
if (i) el.appendChild(document.createTextNode(' '));
var w = document.createElement('span');
w.className = 'isr-word';
w.setAttribute('aria-hidden', 'true');
Array.from(word).forEach(function (c) {
var s = document.createElement('span');
s.className = 'isr-char';
s.textContent = c;
w.appendChild(s);
chars.push(s);
});
el.appendChild(w);
});
return chars;
}
// An SVG filter that roughens the edges with paper-fibre noise. Its
// strength is turned down to nothing as the ink dries.
function filter(el) {
var id = 'isr-filter-' + (++count);
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('width', '0');
svg.setAttribute('height', '0');
svg.style.position = 'absolute';
svg.innerHTML =
'<filter id="' + id + '" x="-10%" y="-20%" width="120%" height="140%">' +
'<feTurbulence type="fractalNoise" baseFrequency="0.035 0.06" numOctaves="3" seed="' + count + '" result="n"/>' +
'<feDisplacementMap in="SourceGraphic" in2="n" scale="0" xChannelSelector="R" yChannelSelector="G"/>' +
'</filter>';
el.appendChild(svg);
return { id: id, map: svg.querySelector('feDisplacementMap') };
}
function play(el) {
var d = el.dataset;
var s = el._isr;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var stagger = +d.stagger || DEFAULTS.stagger;
var from = d.from || DEFAULTS.from;
if (/^\d+$/.test(from)) from = Math.max(0, +from - 1);
var fs = parseFloat(getComputedStyle(el).fontSize);
var ink = { wet: 1 };
var total = duration + stagger * s.chars.length;
function wetness() {
s.f.map.setAttribute('scale', (ink.wet * fs * 0.3).toFixed(2));
}
el.style.filter = 'url(#' + s.f.id + ')';
var tl = anime.createTimeline({
delay: +d.delay || DEFAULTS.delay,
onComplete: function () {
el.style.filter = '';
if (loop) setTimeout(function () { fadeOut(); }, +d.hold || DEFAULTS.hold);
}
});
tl.add(s.chars, {
opacity: [0, 1],
scale: [1.35, 1],
filter: ['blur(' + (fs * 0.16).toFixed(1) + 'px)', 'blur(0px)'],
duration: duration,
ease: 'out(3)',
delay: anime.stagger(stagger, { from: from })
}, 0);
tl.add(ink, { wet: [1, 0], duration: total, ease: 'out(2)', onUpdate: wetness }, 0);
function fadeOut() {
anime.animate(s.chars, {
opacity: 0,
filter: 'blur(' + (fs * 0.1).toFixed(1) + 'px)',
duration: 700,
ease: 'in(2)',
onComplete: function () { setTimeout(function () { play(el); }, 300); }
});
}
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-ink-spread-reveal]').forEach(function (el) {
if (el._isr) return;
var chars = split(el);
el._isr = { chars: chars };
if (reduced()) return;
el._isr.f = filter(el);
chars.forEach(function (c) { c.style.opacity = 0; });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-ink-spread-reveal>Written in ink</h1>
<!-- or, spreading from the first letter, looping: -->
<h1 data-ink-spread-reveal data-from="first" data-loop>Written in ink</h1>