Liquid Text Fill
Hollow, outlined letters fill up with colour from the bottom, with a wavy surface sloshing across the top, like a glass being filled. Loop it and the liquid drains away before filling again.
Fill me up
How to use it
- Paste the snippet just before
</head>. - Add
data-liquid-text-fillto a heading or short line of text. The outline takes the element’s own text colour.
Options
Add any of these attributes to the same element.
data-color | The liquid’s colour. Default #8b5cf6. |
data-wave | How tall the waves are, as a fraction of the font size. Default 0.07. |
data-loop | Fill, hold, drain and fill again. |
data-hold | How long it stays full before draining, when looping. Default 1600 ms. |
data-duration | Time to fill to the top. Default 2600 ms. |
data-delay | Wait before starting. Default 0 ms. |
The waves pause while the text is off screen.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Liquid Text Fill: outlined text fills with colour from the bottom up, with a
// wavy surface sloshing on top, like a glass filling up.
// Usage: <h1 data-liquid-text-fill>Fill me up</h1>
// Optional: data-color (the liquid, default #8b5cf6), data-loop (fill, hold,
// drain and fill again), data-hold (ms to stay full before draining),
// data-duration (ms to fill), data-wave (wave height as a fraction of the
// font size, default 0.07), data-delay (ms before starting).
// The outline uses the element's own text colour.
var DEFAULTS = { color: '#8b5cf6', duration: 2600, hold: 1600, wave: 0.07, delay: 0 };
var css =
'.ltf-on{position:relative;color:transparent!important}' +
'.ltf-fill{position:absolute;inset:0;pointer-events:none;-webkit-text-stroke:0;' +
'clip-path:inset(100% 0 0 0)}';
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 d = el.dataset;
var cs = getComputedStyle(el);
var fs = parseFloat(cs.fontSize);
var text = el.textContent.replace(/\s+/g, ' ').trim();
var color = d.color || DEFAULTS.color;
el.setAttribute('aria-label', text);
el.textContent = '';
// The outline: the element's own text, hollowed out.
var line = document.createElement('span');
line.setAttribute('aria-hidden', 'true');
line.textContent = text;
line.style.webkitTextStroke = Math.max(1.5, fs * 0.022).toFixed(2) + 'px ' + cs.color;
el.appendChild(line);
el.classList.add('ltf-on');
// Two copies of the liquid: a paler back wave and the main one in front.
var layers = [0.45, 1].map(function (alpha) {
var f = document.createElement('span');
f.className = 'ltf-fill';
f.setAttribute('aria-hidden', 'true');
f.textContent = text;
f.style.color = color;
f.style.opacity = alpha;
f.style.padding = cs.padding;
el.appendChild(f);
return f;
});
return { layers: layers, level: 0, amp: fs * (+d.wave || DEFAULTS.wave), t: 0 };
}
// A polygon whose top edge is a sine wave at the current level.
function wave(w, h, level, amp, phase, lift) {
var y0 = (h + amp * 2) * (1 - level) - amp - lift;
var pts = [], n = 24;
for (var i = 0; i <= n; i++) {
var x = (w * i) / n;
var y = y0 + amp * Math.sin((x / w) * Math.PI * 2.4 + phase) +
amp * 0.35 * Math.sin((x / w) * Math.PI * 5.3 - phase * 1.7);
pts.push(x.toFixed(1) + 'px ' + y.toFixed(1) + 'px');
}
pts.push(w + 'px ' + (h + 2) + 'px', '0px ' + (h + 2) + 'px');
return 'polygon(' + pts.join(',') + ')';
}
function render(el, s) {
var w = el.offsetWidth, h = el.offsetHeight;
// Calm the surface as the glass gets full or empty.
var amp = s.amp * (0.35 + 0.65 * Math.sin(Math.PI * Math.min(1, Math.max(0, s.level))));
s.layers[0].style.clipPath = wave(w, h, s.level, amp, s.t * 0.0031 + 2.2, amp * 0.6);
s.layers[1].style.clipPath = wave(w, h, s.level, amp, -s.t * 0.0024, 0);
}
function play(el, s) {
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var last = 0;
function frame(now) {
if (last) s.t += now - last;
last = now;
if (s.visible) render(el, s);
if (s.visible && (loop || s.level < 1.05)) requestAnimationFrame(frame);
else last = 0;
}
s.kick = function () { requestAnimationFrame(frame); };
s.kick();
var tl = anime.createTimeline({ loop: loop, loopDelay: 500, delay: +d.delay || DEFAULTS.delay });
tl.add(s, { level: [0, 1.06], duration: duration, ease: 'inOut(1.6)' });
if (loop) {
tl.add(s, { level: [1.06, 0], duration: duration * 0.55, ease: 'inOut(2)' },
'+=' + (+d.hold || DEFAULTS.hold));
}
}
function init() {
// Watches each element for as long as it lives: starts it the first time
// it scrolls into view and pauses the waves while it's off screen.
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._ltf;
var was = s.visible;
s.visible = e.isIntersecting;
if (!s.visible) return;
if (!s.started) { s.started = true; play(el, s); }
else if (!was && s.kick) s.kick();
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-liquid-text-fill]').forEach(function (el) {
if (el._ltf) return;
var s = setup(el);
el._ltf = s;
if (reduced()) {
s.layers.forEach(function (f) { f.style.clipPath = 'none'; });
return;
}
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-liquid-text-fill>Fill me up</h1>
<!-- or, with pink liquid, looping: -->
<h1 data-liquid-text-fill data-color="#ec4899" data-loop>Fill me up</h1>