Fill Sweep Button
Hover the button and colour floods in from the side your pointer came in, with the text turning white to match. Move away and it drains out the side you left by, so the button seems to follow your hand.
How to use it
- Paste the snippet just before
</head>. - Add
data-fill-sweep-buttonto a button or link, ideally an outlined one.
Options
Add any of these attributes to the same element.
data-color | The fill colour. Default #6a0dad. |
data-text-color | Text colour over the fill. Default white. |
data-from | Always fill from left, right, top or bottom. By default it follows the pointer. |
data-duration | How long the sweep takes. Default 450 ms. |
data-autoplay | It fills and empties by itself every couple of seconds while in view. Handy for demos. |
Keyboard focus fills it too. With reduced motion the colour swaps instantly.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Fill Sweep Button: hover the button and a colour floods in from the side
// the pointer came in, while the text changes colour to stay readable. Move
// away and the colour drains out the side the pointer left by. Keyboard
// focus fills it too.
// Usage: <button data-fill-sweep-button>Get in touch</button>
// Optional: data-color (the fill, default #6a0dad), data-text-color (text
// colour over the fill, default #fff), data-from ("left", "right", "top" or
// "bottom" to always fill from that side; default: the side the pointer
// enters), data-duration (ms, default 450), data-autoplay (it fills and
// empties by itself every couple of seconds while in view; handy for demos).
// With reduced motion the colour swaps instantly instead of sweeping.
var DEFAULTS = { color: '#6a0dad', text: '#fff', duration: 450, every: 2400 };
var SIDES = { left: ['x', -1], right: ['x', 1], top: ['y', -1], bottom: ['y', 1] };
var css =
'.fsb{position:relative;overflow:hidden;isolation:isolate}' +
'.fsb-fill{position:absolute;inset:-1px;z-index:-1;pointer-events:none;background:var(--fsb-color);transform:translateX(-101%)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// The edge nearest the pointer: the side it crossed to get in (or out).
function nearestSide(el, e) {
var r = el.getBoundingClientRect();
var dx = (e.clientX - r.left) / r.width - 0.5;
var dy = (e.clientY - r.top) / r.height - 0.5;
// Scale by the shape so a wide button doesn't always pick top or bottom.
if (Math.abs(dx) * r.width > Math.abs(dy) * r.height) return dx < 0 ? 'left' : 'right';
return dy < 0 ? 'top' : 'bottom';
}
function offset(side) {
var s = SIDES[side] || SIDES.left;
return { x: s[0] === 'x' ? s[1] * 101 + '%' : '0%', y: s[0] === 'y' ? s[1] * 101 + '%' : '0%' };
}
function setup(el) {
var d = el.dataset;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var duration = reduced ? 0 : +d.duration || DEFAULTS.duration;
var fixed = SIDES[d.from] ? d.from : null;
el.classList.add('fsb');
el.style.setProperty('--fsb-color', d.color || DEFAULTS.color);
var fill = document.createElement('span');
fill.className = 'fsb-fill';
fill.setAttribute('aria-hidden', 'true');
el.insertBefore(fill, el.firstChild);
var baseColor = getComputedStyle(el).color;
var filled = false;
function into(side) {
if (filled) return;
filled = true;
var from = offset(fixed || side);
anime.animate(fill, { x: [from.x, '0%'], y: [from.y, '0%'], duration: duration, ease: 'out(3)' });
anime.animate(el, { color: d.textColor || DEFAULTS.text, duration: duration * 0.6, ease: 'out(2)' });
}
function out(side) {
if (!filled) return;
filled = false;
var to = offset(fixed || side);
anime.animate(fill, { x: to.x, y: to.y, duration: duration * 0.85, ease: 'inOut(2)' });
anime.animate(el, {
color: baseColor, duration: duration * 0.6, delay: duration * 0.25, ease: 'out(2)',
onComplete: function () { if (!filled) el.style.color = ''; }
});
}
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') into(nearestSide(el, e)); });
el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') out(nearestSide(el, e)); });
el.addEventListener('focus', function () { if (!el.matches(':hover')) into('left'); });
el.addEventListener('blur', function () { if (!el.matches(':hover')) out('right'); });
if (!reduced && d.autoplay !== undefined && d.autoplay !== 'false') {
// A pretend pointer that passes through from a different side each time.
var passes = [['left', 'right'], ['bottom', 'top'], ['right', 'left'], ['top', 'bottom']];
var n = 0;
var timer = null;
var pass = function () {
if (!el.matches(':hover') && document.activeElement !== el) {
var p = passes[n++ % passes.length];
into(p[0]);
setTimeout(function () { if (!el.matches(':hover') && document.activeElement !== el) out(p[1]); }, DEFAULTS.every * 0.5);
}
timer = setTimeout(pass, DEFAULTS.every);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(pass, 500);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-fill-sweep-button]').forEach(function (el) {
if (el._fsb) return;
el._fsb = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><a class="button" href="/contact" data-fill-sweep-button>Get in touch</a>
<!-- always from below, in pink: -->
<a class="button" href="/contact" data-fill-sweep-button data-from="bottom" data-color="#db2777">Get in touch</a>