Image Hover Reveal
Hover a link in a list and its picture appears behind it, floating after the pointer and leaning into the movement. Move to the next link and its picture wipes up over the last one, while the other links dim.
How to use it
- Paste the snippet just before
</head>. - Add
data-image-hover-revealto a list of links. - Give each link its picture with
data-img="photo.jpg", or put an<img>or<svg>markeddata-revealinside it.
Options
Add any of these attributes to the same element.
data-width | Picture width, in px. Default 220. |
data-ratio | Picture shape as width / height, e.g. 16/9. Default 4/3. |
data-tilt | Most the picture leans while moving, in degrees. Default 10. |
data-autoplay | A pretend pointer runs down the list while a real one isn’t over it. Handy for demos and touch screens. |
Pictures show behind the link text, so bold, large links read best. Keyboard focus shows the picture beside the link.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Image Hover Reveal: hover a link in a list and its picture appears behind
// it, floating after the pointer and leaning into the movement. Move to the
// next link and the next picture wipes up over the last; the other links
// dim while one is shown.
// Usage: <ul data-image-hover-reveal>
// <li><a href="/lakes" data-img="lake.jpg">Lakes</a></li>
// <li><a href="/aurora" data-img="aurora.jpg">Aurora</a></li>
// </ul>
// Instead of data-img, a link can hold its own picture (an <img>, <svg> or
// anything else) marked data-reveal.
// Optional (on the list): data-width (picture width in px, default 220),
// data-ratio (width / height, default 4/3), data-tilt (most it leans, in
// degrees, default 10), data-autoplay (a pretend pointer runs down the list
// while no real one is over it; handy for demos and touch screens).
// Keyboard focus shows the picture too.
var DEFAULTS = { width: 220, ratio: 4 / 3, tilt: 10 };
var css =
'[data-image-hover-reveal] [data-reveal]{display:none}' +
'.ihr-item{position:relative;z-index:1}' +
'.ihr-frame{position:absolute;left:0;top:0;z-index:0;pointer-events:none;border-radius:12px;overflow:hidden;opacity:0;' +
'box-shadow:0 18px 40px rgba(15,15,30,.28)}' +
'.ihr-pic{position:absolute;inset:0;overflow:hidden}' +
'.ihr-pic > *{display:block !important;width:100%;height:100%;object-fit:cover}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function setup(el) {
var d = el.dataset;
var width = +d.width || DEFAULTS.width;
var ratio = d.ratio ? ratioOf(d.ratio) : DEFAULTS.ratio;
var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
var height = width / ratio;
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var frame = document.createElement('div');
frame.className = 'ihr-frame';
frame.setAttribute('aria-hidden', 'true');
frame.style.width = width + 'px';
frame.style.height = height + 'px';
el.appendChild(frame);
// Each link with a picture, and that picture waiting in the frame.
var items = [];
el.querySelectorAll('[data-img], [data-reveal]').forEach(function (n) {
var link = n.hasAttribute('data-img') ? n : n.parentElement;
if (items.some(function (it) { return it.el === link; })) return;
var pic = document.createElement('div');
pic.className = 'ihr-pic';
if (link.dataset.img) {
var img = document.createElement('img');
img.src = link.dataset.img;
img.alt = '';
img.loading = 'lazy';
pic.appendChild(img);
} else {
pic.appendChild(n);
}
pic.style.visibility = 'hidden';
frame.appendChild(pic);
link.classList.add('ihr-item');
items.push({ el: link, pic: pic });
});
if (!items.length) return;
var move = anime.createAnimatable(frame, { x: 650, y: 650, rotate: 500, ease: 'out(3)' });
var current = null, shown = false, layer = 1, lastX = null, settle;
function show(item) {
if (item === current) return;
current = item;
items.forEach(function (it) {
anime.animate(it.el, { opacity: !item || it === item ? 1 : 0.35, duration: 350, ease: 'out(2)' });
});
if (!item) {
shown = false;
anime.animate(frame, { opacity: 0, scale: 0.7, duration: 300, ease: 'in(2)' });
return;
}
// The new picture wipes up over the last one, settling from a slight zoom.
var pic = item.pic;
pic.style.visibility = 'visible';
pic.style.zIndex = ++layer;
anime.animate(pic, { clipPath: ['inset(100% 0% 0% 0%)', 'inset(0% 0% 0% 0%)'], duration: shown ? 550 : 0, ease: 'out(4)' });
anime.animate(pic.firstChild, { scale: [1.3, 1], duration: 900, ease: 'out(3)' });
if (!shown) {
shown = true;
anime.animate(frame, { opacity: [0, 1], scale: [0.6, 1], duration: 450, ease: 'out(3)' });
}
}
// Point the frame at (x, y), in the list's own coordinates.
function aim(x, y, now) {
// (Always pass the duration: an animatable keeps the last one it was given.)
move.x(x - width / 2, now ? 0 : 650);
move.y(y - height / 2, now ? 0 : 650);
if (lastX !== null && !now) {
move.rotate(Math.max(-tilt, Math.min(tilt, (x - lastX) * 0.6)));
clearTimeout(settle);
settle = setTimeout(function () { move.rotate(0); }, 90);
}
lastX = x;
}
// Which link is at (x, y) on screen, if any.
function hit(x, y) {
for (var i = 0; i < items.length; i++) {
var r = items[i].el.getBoundingClientRect();
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return items[i];
}
return null;
}
function pointAt(cx, cy) {
var b = el.getBoundingClientRect();
var item = hit(cx, cy);
aim(cx - b.left, cy - b.top, !shown && item);
show(item);
}
var real = false;
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') return;
real = true;
if (tour) tour.pause();
pointAt(e.clientX, e.clientY);
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
real = false;
lastX = null;
show(null);
if (tour && visible) setTimeout(function () { if (!real && visible) tour.play(); }, 800);
});
// Keyboard: show the picture beside the focused link.
el.addEventListener('focusin', function (e) {
var item = items.filter(function (it) { return it.el.contains(e.target); })[0];
if (!item || real) return;
var b = el.getBoundingClientRect(), r = item.el.getBoundingClientRect();
aim(r.right - b.left + width * 0.2, r.top + r.height / 2 - b.top, !shown);
show(item);
});
el.addEventListener('focusout', function () { if (!real) show(null); });
// A pretend pointer that wanders down the list, one link at a time.
var tour = null, visible = false;
var pos = { x: 0, y: 0 };
function follow() {
if (real) return;
var b = el.getBoundingClientRect();
pointAt(b.left + pos.x, b.top + pos.y);
}
function plan() {
var b = el.getBoundingClientRect();
pos.x = b.width * 0.2;
pos.y = -height * 0.6;
tour = anime.createTimeline({ autoplay: false, onComplete: function () { plan(); if (visible && !real) tour.play(); } });
var at = 0;
items.forEach(function (it, i) {
var r = it.el.getBoundingClientRect();
var x = r.left - b.left + r.width * (i % 2 ? 0.62 : 0.32);
var y = r.top - b.top + r.height / 2;
tour.add(pos, { x: x, y: y, duration: 800, ease: 'inOut(3)', onUpdate: follow }, at);
// A lazy sideways drift while it lingers, so the picture leans.
tour.add(pos, { x: x + (i % 2 ? -1 : 1) * r.width * 0.18, duration: 1100, ease: 'inOut(2)', onUpdate: follow }, at + 800);
at += 1900;
});
tour.add(pos, { x: b.width * 0.8, y: b.height + height * 0.6, duration: 800, ease: 'in(2)', onUpdate: follow }, at);
tour.call(function () {}, at + 1400);
}
if (d.autoplay !== undefined && d.autoplay !== 'false') {
plan();
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (real) return;
if (visible) tour.play(); else tour.pause();
}).observe(el);
}
}
// "4/3", "1.5" or "16:9".
function ratioOf(s) {
var p = s.split(/[\/:]/);
var r = p.length > 1 ? +p[0] / +p[1] : +p[0];
return r > 0 ? r : DEFAULTS.ratio;
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-image-hover-reveal]').forEach(function (el) {
if (el._ihr) return;
el._ihr = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><ul data-image-hover-reveal>
<li><a href="/lakes" data-img="lake.jpg">Lakes</a></li>
<li><a href="/aurora" data-img="aurora.jpg">Aurora</a></li>
<li><a href="/dunes" data-img="dunes.jpg">Dunes</a></li>
</ul>