Direction-aware Hover
Hover a tile and its caption slides in from whichever side the pointer came in by. Move off and it slides out through the side you left by, so the overlay seems to follow your hand.
How to use it
- Paste the snippet just before
</head>. - Add
data-direction-aware-hoverto a tile, card or image link. - Put the overlay inside it, marked
data-overlay, or give the tile adata-labeland the script makes a simple one.
Options
Add any of these attributes to the same element.
data-label | Text for an overlay the script makes itself, when there’s no data-overlay inside. |
data-duration | How long the slide takes. Default 450 ms. |
data-autoplay | A pretend pointer passes in and out from random sides while a real one isn’t over it. Neighbouring tiles take turns. Handy for demos. |
The tile clips the overlay, and the script gives the overlay a purple tint you can restyle with your own CSS. On touch screens a tap brings it in from the nearest edge.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Direction-aware Hover: an overlay slides in from whichever side the pointer
// came in by, and slides out through the side it leaves by.
// Usage: <div data-direction-aware-hover><img src="…" alt=""><div data-overlay>Caption</div></div>
// Optional: data-label (text for an overlay the script makes itself, when
// there's no [data-overlay] child), data-duration (ms, default 450),
// data-autoplay (a pretend pointer passes in and out from random sides while
// a real one isn't over it; handy for demos and touch screens).
var DEFAULTS = { duration: 450 };
var SIDES = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
var NAMES = ['top', 'right', 'bottom', 'left'];
var css =
'.dah{position:relative;overflow:hidden}' +
'.dah-ov{position:absolute;inset:0;display:grid;place-content:center;gap:4px;padding:12px;text-align:center;' +
'background:rgba(76,9,128,.82);color:#fff;font-weight:700;visibility:hidden;pointer-events:none;will-change:transform}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Which edge is (x, y) nearest to, allowing for the element's shape.
function sideOf(el, x, y) {
var r = el.getBoundingClientRect();
var u = (x - r.left - r.width / 2) / (r.width / 2);
var v = (y - r.top - r.height / 2) / (r.height / 2);
if (Math.abs(u) > Math.abs(v)) return u > 0 ? 'right' : 'left';
return v > 0 ? 'bottom' : 'top';
}
function setup(el) {
var d = el.dataset;
var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
el.classList.add('dah');
var ov = el.querySelector('[data-overlay]');
if (!ov) {
ov = document.createElement('div');
ov.textContent = d.label || '';
ov.setAttribute('aria-hidden', 'true');
el.appendChild(ov);
}
ov.classList.add('dah-ov');
var shown = false, out = true, anim = null;
function off(side) {
var s = SIDES[side];
return { x: s[0] * el.offsetWidth, y: s[1] * el.offsetHeight };
}
function enter(side) {
if (shown) return;
shown = true;
if (anim) anim.pause();
// Start from the entry side, unless it's still on its way out.
if (out) anime.utils.set(ov, off(side));
out = false;
ov.style.visibility = 'visible';
anim = anime.animate(ov, { x: 0, y: 0, duration: reduced() ? 0 : duration, ease: 'out(3)' });
}
function leave(side) {
if (!shown) return;
shown = false;
if (anim) anim.pause();
var to = off(side);
anim = anime.animate(ov, {
x: to.x,
y: to.y,
duration: reduced() ? 0 : duration * 0.9,
ease: 'inOut(2)',
onComplete: function () { out = true; ov.style.visibility = 'hidden'; }
});
}
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
el._dahReal = true;
enter(sideOf(el, e.clientX, e.clientY));
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
el._dahReal = false;
leave(sideOf(el, e.clientX, e.clientY));
});
// Touch: a tap brings it in from (or sends it out by) the nearest edge.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
el._dahHold = Date.now() + 5000;
var side = sideOf(el, e.clientX, e.clientY);
if (shown) leave(side);
else enter(side);
});
el.addEventListener('focusin', function () { enter('bottom'); });
el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) leave('bottom'); });
return { enter: enter, leave: leave, isShown: function () { return shown; } };
}
// A pretend pointer passes in and out, from random sides.
function autoplay(el, api, offset) {
var visible = false, timer = null, mine = false;
function pick(not) {
var s;
do { s = NAMES[Math.floor(Math.random() * 4)]; } while (s === not);
return s;
}
var last = null;
function tick() {
if (!visible) return;
var busy = el._dahReal || (el._dahHold && Date.now() < el._dahHold);
if (busy) { mine = false; timer = setTimeout(tick, 800); return; }
if (mine && api.isShown()) {
api.leave((last = pick(last)));
mine = false;
timer = setTimeout(tick, 1300);
} else if (!api.isShown()) {
api.enter((last = pick(last)));
mine = true;
timer = setTimeout(tick, 1400);
} else {
timer = setTimeout(tick, 800);
}
}
new IntersectionObserver(function (entries) {
var was = visible;
visible = entries[0].isIntersecting;
if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 300 + offset); }
}).observe(el);
}
function init() {
var counts = [];
document.querySelectorAll('[data-direction-aware-hover]').forEach(function (el) {
if (el._dah) return;
el._dah = true;
var api = setup(el);
var auto = el.dataset.autoplay;
if (auto === undefined || auto === 'false' || reduced()) return;
// Neighbours take turns, so a grid ripples rather than flashing at once.
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++ * 900);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><a class="tile" href="/lakes" data-direction-aware-hover>
<img src="lakes.jpg" alt="Lakes">
<div data-overlay>Lakes</div>
</a>