Text Fill Sweep
Hover the text and colour floods into it from left to right, like ink filling the letters. When the pointer leaves, the colour carries on out to the right. It can fill an outline, too.
How to use it
- Paste the snippet just before
</head>. - Add
data-text-fill-sweepto a link, heading or word.
Options
Add any of these attributes to the same element.
data-color | Fill colour. Default #a855f7. Several comma-separated colours make a gradient. |
data-outline | Show the text as an outline until it fills. |
data-reverse | Drain the colour back to the left on leave, instead of carrying on. |
data-duration | How long the sweep takes. Default 650 ms. |
data-autoplay | Fill and empty on a timer while the pointer isn’t over it. Handy for demos and touch screens. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Text Fill Sweep: hover the text and colour floods into it from left to
// right. When the pointer leaves, the colour carries on out to the right.
// Usage: <a href="/work" data-text-fill-sweep>Our work</a>
// Optional: data-color (fill colour, default #a855f7; two or more
// comma-separated colours make a gradient), data-outline (the text sits as
// an outline until it fills), data-reverse (the colour drains back to the
// left instead of carrying on), data-duration (ms, default 650),
// data-autoplay (fill and empty on a timer while the pointer isn't over it;
// handy for demos and touch screens).
var DEFAULTS = { color: '#a855f7', duration: 650 };
var css =
'.tfs{position:relative;display:inline-block;text-decoration:none}' +
'.tfs-fill{position:absolute;inset:0;pointer-events:none;clip-path:inset(-10% 100% -10% 0)}' +
'.tfs-grad{-webkit-background-clip:text;background-clip:text;color:transparent!important}';
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 duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
var colors = (d.color || DEFAULTS.color).split(',');
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.classList.add('tfs');
if (d.outline !== undefined && d.outline !== 'false') {
var ink = getComputedStyle(el).color;
var w = Math.max(1, Math.round(parseFloat(getComputedStyle(el).fontSize) / 40));
el.style.color = 'transparent';
el.style.webkitTextStroke = w + 'px ' + ink;
}
var fill = document.createElement('span');
fill.className = 'tfs-fill';
fill.setAttribute('aria-hidden', 'true');
fill.textContent = text;
fill.style.webkitTextStroke = '0';
if (colors.length > 1) {
fill.classList.add('tfs-grad');
fill.style.backgroundImage = 'linear-gradient(90deg,' + colors.join(',') + ')';
} else {
fill.style.color = colors[0];
}
el.appendChild(fill);
// The colour covers l to r, each 0 (left edge) to 1 (right edge).
var span = { l: 0, r: 0 };
var anim = null, on = false;
function draw() {
fill.style.clipPath = 'inset(-10% ' + ((1 - span.r) * 100).toFixed(2) + '% -10% ' + (span.l * 100).toFixed(2) + '%)';
}
function enter() {
if (on) return;
on = true;
if (anim) anim.pause();
// Still sweeping out to the right: start again from the left edge.
if (span.l > 0) span.l = span.r = 0;
anim = anime.animate(span, { l: 0, r: 1, duration: reduced() ? 0 : duration, ease: 'out(3)', onUpdate: draw });
}
function leave() {
if (!on) return;
on = false;
if (anim) anim.pause();
var back = d.reverse !== undefined && d.reverse !== 'false';
anim = anime.animate(span, {
l: back ? 0 : 1,
r: back ? 0 : 1,
duration: reduced() ? 0 : duration,
ease: 'inOut(3)',
onUpdate: draw,
onComplete: function () { span.l = span.r = 0; draw(); }
});
}
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { el._tfsReal = true; enter(); } });
el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { el._tfsReal = false; leave(); } });
// Touch: a tap fills it, another tap empties it.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
el._tfsHold = Date.now() + 5000;
if (on) leave();
else enter();
});
el.addEventListener('focus', enter);
el.addEventListener('blur', leave);
return { enter: enter, leave: leave, isOn: function () { return on; } };
}
function autoplay(el, api, offset) {
var visible = false, timer = null, mine = false;
function tick() {
if (!visible) return;
var busy = el._tfsReal || el === document.activeElement || (el._tfsHold && Date.now() < el._tfsHold);
if (busy) { mine = false; timer = setTimeout(tick, 800); return; }
if (mine) { api.leave(); mine = false; timer = setTimeout(tick, 1200); }
else if (!api.isOn()) { api.enter(); mine = true; timer = setTimeout(tick, 1500); }
else timer = setTimeout(tick, 800);
}
new IntersectionObserver(function (entries) {
var was = visible;
visible = entries[0].isIntersecting;
if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 400 + offset); }
}).observe(el);
}
function init() {
var counts = [];
document.querySelectorAll('[data-text-fill-sweep]').forEach(function (el) {
if (el._tfs) return;
el._tfs = true;
var api = setup(el);
var auto = el.dataset.autoplay;
if (auto === undefined || auto === 'false' || reduced()) return;
var g = counts.filter(function (c) { return c.parent === el.parentElement; })[0];
if (!g) counts.push((g = { parent: el.parentElement, n: 0 }));
autoplay(el, api, g.n++ * 600);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><a href="/studio" data-text-fill-sweep>Studio</a>
<!-- an outline that fills with a gradient: -->
<a href="/work" data-text-fill-sweep data-outline data-color="#a855f7,#ec4899">Work</a>