Border Glow Follow
A card’s border lights up in a two-colour glow right where the pointer is, with a soft bloom, and the light slides round the edge as the pointer moves. On a grid, neighbouring cards catch the light as the pointer nears them.
Starter$0/moFor side projects
Pro$12/moFor growing teams
Studio$29/moFor agencies
How to use it
- Paste the snippet just before
</head>. - Add
data-border-glow-followto a card, ordata-border-glow-follow data-groupto the container of a grid of cards.
Options
Add any of these attributes to the same element.
data-group | Light up the element’s children instead of the element itself. |
data-colors | Two colours for the light. Default #c084fc,#22d3ee. |
data-size | Radius of the light, in px. Default 180. |
data-width | Width of the glowing border, in px. Default the card’s own border, or 2. |
data-autoplay | The light roams on its own while the pointer isn’t near. Handy for demos and touch screens. |
The glow draws over each card’s own border, so give cards a quiet border and rounded corners. It looks best on a dark section.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Border Glow Follow: a card's border lights up where the pointer is, with a
// soft bloom, and the light slides round the edge as the pointer moves. On a
// grid, the borders of neighbouring cards catch the light too.
// Usage: <div class="card" data-border-glow-follow>…</div>
// or: <div class="grid" data-border-glow-follow data-group>…cards…</div>
// Optional: data-group (light up the element's children instead of the
// element itself), data-colors (two colours for the light, default
// #c084fc,#22d3ee), data-size (radius of the light in px, default 180),
// data-width (glowing border width in px, default the card's own border or
// 2), data-autoplay (the light roams on its own while the pointer isn't
// near; handy for demos and touch screens).
// Cards keep their own border and background: the glow draws over the border.
var DEFAULTS = { colors: '#c084fc,#22d3ee', size: 180 };
var css =
'.bgf-card{position:relative}' +
'.bgf-edge, .bgf-bloom{position:absolute;pointer-events:none;border-radius:inherit;opacity:var(--bgf-o,0)}' +
'.bgf-edge{inset:calc(-1 * var(--bgf-bw));padding:var(--bgf-w);' +
'background:radial-gradient(circle var(--bgf-size) at var(--bgf-x) var(--bgf-y),var(--bgf-c1),var(--bgf-c2) 45%,transparent 75%);' +
'-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;' +
'mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);mask-composite:exclude}' +
'.bgf-bloom{inset:0;filter:blur(8px)}' +
'.bgf-bloom .bgf-edge{--bgf-w:5px;opacity:1}' +
'.bgf-inner{position:absolute;inset:0;pointer-events:none;border-radius:inherit;opacity:var(--bgf-o,0);' +
'background:radial-gradient(circle var(--bgf-size) at var(--bgf-x) var(--bgf-y),color-mix(in srgb,var(--bgf-c1) 14%,transparent),transparent 70%)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function decorate(card, o) {
var cs = getComputedStyle(card);
if (cs.position === 'static') card.classList.add('bgf-card');
var bw = parseFloat(cs.borderTopWidth) || 0;
card.style.setProperty('--bgf-bw', bw + 'px');
card.style.setProperty('--bgf-w', (o.width || Math.max(bw, 2)) + 'px');
card.style.setProperty('--bgf-size', o.size + 'px');
card.style.setProperty('--bgf-c1', o.colors[0]);
card.style.setProperty('--bgf-c2', o.colors[1] || o.colors[0]);
var inner = document.createElement('div');
inner.className = 'bgf-inner';
var bloom = document.createElement('div');
bloom.className = 'bgf-bloom';
bloom.innerHTML = '<div class="bgf-edge"></div>';
var edge = document.createElement('div');
edge.className = 'bgf-edge';
[inner, bloom, edge].forEach(function (n) { n.setAttribute('aria-hidden', 'true'); card.appendChild(n); });
return { el: card, o: 0 };
}
function setup(el) {
var d = el.dataset;
var o = {
colors: (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); }),
size: +d.size || DEFAULTS.size,
width: +d.width || 0
};
var group = d.group !== undefined && d.group !== 'false';
var cards = (group ? Array.prototype.slice.call(el.children) : [el]).map(function (c) { return decorate(c, o); });
// The light glides after the pointer (screen coordinates).
var light = { x: 0, y: 0 }, aim = { x: 0, y: 0 };
var real = false, near = false, visible = false, placed = false;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var t = Math.random() * 10;
function paint() {
cards.forEach(function (c) {
var r = c.el.getBoundingClientRect();
// Full strength inside the card, fading out over the light's radius.
var dx = Math.max(r.left - light.x, 0, light.x - r.right);
var dy = Math.max(r.top - light.y, 0, light.y - r.bottom);
var want = (real || autoplay) ? Math.max(0, 1 - Math.hypot(dx, dy) / (o.size * 0.75)) : 0;
c.o += (want - c.o) * 0.18;
c.el.style.setProperty('--bgf-x', (light.x - r.left).toFixed(1) + 'px');
c.el.style.setProperty('--bgf-y', (light.y - r.top).toFixed(1) + 'px');
c.el.style.setProperty('--bgf-o', c.o.toFixed(3));
});
}
var ticker = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
if (!real && autoplay) {
// A slow wander back and forth across the cards.
t += 0.011;
var b = el.getBoundingClientRect();
aim.x = b.left + b.width * (0.5 + 0.44 * Math.sin(t * 0.9));
aim.y = b.top + b.height * (0.5 + 0.3 * Math.sin(t * 1.6 + 1));
}
if (!placed) { light.x = aim.x; light.y = aim.y; placed = true; }
light.x += (aim.x - light.x) * 0.16;
light.y += (aim.y - light.y) * 0.16;
paint();
// Nothing left to draw: rest until the pointer comes back.
if (!real && !autoplay && cards.every(function (c) { return c.o < 0.005; })) ticker.pause();
}
});
function wake() { if (visible || !autoplay) ticker.play(); }
// Track the pointer across the page, so cards light up as it nears them.
document.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') return;
var b = el.getBoundingClientRect();
var reach = o.size * 0.75;
near = e.clientX > b.left - reach && e.clientX < b.right + reach && e.clientY > b.top - reach && e.clientY < b.bottom + reach;
if (!near) { if (real) { real = false; if (!autoplay) wake(); } return; }
if (!real && !autoplay) placed = false;
real = true;
aim.x = e.clientX;
aim.y = e.clientY;
wake();
}, { passive: true });
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && autoplay) ticker.play();
else if (!visible) ticker.pause();
}).observe(el);
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-border-glow-follow]').forEach(function (el) {
if (el._bgf) return;
el._bgf = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="pricing" data-border-glow-follow data-group>
<div class="card">…</div>
<div class="card">…</div>
<div class="card">…</div>
</div>