Image Trail
Move the pointer across the area and it deals out a trail of photos behind it, each one sliding out from the last and tipping slightly, before shrinking and fading away.
Move to explore
How to use it
- Paste the snippet just before
</head>. - Add
data-image-trailto the area. - Put the photos inside it, each marked
data-trail-image. They’re hidden and dealt out in turn.
Options
Add any of these attributes to the same element.
data-size | Width of each photo, in px. Default a quarter of the area, up to 150. |
data-ratio | Height of each photo over its width. Default 1.25. |
data-spacing | How far the pointer travels between photos, in px. Default 0.7 times the width. |
data-life | How long each photo stays before fading. Default 650 ms. |
data-autoplay | A pretend pointer roams the area while a real one isn’t in it. Handy for demos and touch screens. |
The photos sit over the area without catching clicks. On touch screens a tap deals a photo where your finger lands. With reduced motion turned on, there’s no trail.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Image Trail: move the pointer across an area and it deals out a trail of
// photos behind it, each one sliding out from the last, tipping slightly,
// then shrinking and fading away.
// Usage:
// <section data-image-trail>
// <h2>Move to explore</h2>
// <img data-trail-image src="one.jpg" alt="">
// <img data-trail-image src="two.jpg" alt="">
// </section>
// The children marked data-trail-image are hidden and dealt out in turn.
// Optional: data-size (photo width, px, default a quarter of the area up to
// 150), data-ratio (photo height over width, default 1.25), data-spacing
// (px the pointer travels between photos, default 0.7 times the width),
// data-life (ms each photo stays before fading, default 650), data-autoplay
// (a pretend pointer roams the area while a real one isn't in it; handy for
// demos and touch screens).
// The photos sit over the area without catching clicks.
var DEFAULTS = { ratio: 1.25, life: 650, max: 14 };
var css =
'.imt{position:relative;overflow:hidden}' +
'.imt-src{position:absolute!important;left:0;top:0;width:2px!important;height:2px!important;opacity:0;pointer-events:none;overflow:hidden}' +
'.imt-layer{position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden}' +
'.imt-item{position:absolute;left:0;top:0;overflow:hidden;border-radius:10px;background:#ddd;' +
'box-shadow:0 12px 28px rgba(20,10,40,.28);will-change:transform,opacity}' +
'.imt-item>*{display:block;width:100%!important;height:100%!important;object-fit:cover;max-width:none;margin:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }
function setup(el) {
var d = el.dataset;
var sources = [].slice.call(el.querySelectorAll('[data-trail-image]'));
if (!sources.length) return;
var ratio = num(d.ratio, DEFAULTS.ratio);
var life = num(d.life, DEFAULTS.life);
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
el.classList.add('imt');
sources.forEach(function (s) { s.classList.add('imt-src'); s.setAttribute('aria-hidden', 'true'); });
var layer = document.createElement('div');
layer.className = 'imt-layer';
layer.setAttribute('aria-hidden', 'true');
el.appendChild(layer);
var w = 0, h = 0, W = 0, H = 0, spacing = 0;
function measure() {
w = el.clientWidth; h = el.clientHeight;
W = num(d.size, Math.round(Math.min(150, Math.max(56, Math.min(w * 0.25, h * 0.42)))));
H = Math.round(W * ratio);
spacing = num(d.spacing, W * 0.7);
}
measure();
new ResizeObserver(measure).observe(el);
var n = 0, live = 0, z = 1, last = null, prev = null;
function spawn(x, y) {
if (live >= DEFAULTS.max) return;
live++;
var item = document.createElement('div');
item.className = 'imt-item';
item.style.width = W + 'px';
item.style.height = H + 'px';
item.style.zIndex = z++;
var copy = sources[n++ % sources.length].cloneNode(true);
copy.classList.remove('imt-src');
copy.removeAttribute('data-trail-image');
copy.removeAttribute('id');
item.appendChild(copy);
layer.appendChild(item);
var from = prev || { x: x, y: y };
prev = { x: x, y: y };
var tilt = Math.random() * 16 - 8;
// Slide out from the last photo to the pointer, then sink away.
anime.animate(item, {
x: [from.x - W / 2, x - W / 2],
y: [from.y - H / 2, y - H / 2],
rotate: [tilt * 0.3, tilt],
scale: [{ from: 0.55, to: 1, duration: 450, ease: 'out(3)' }],
duration: 650,
ease: 'out(3)'
});
anime.animate(item, {
opacity: [{ from: 1, to: 1, duration: life }, { to: 0, duration: 500, ease: 'in(2)' }],
onComplete: function () { item.remove(); live--; }
});
anime.animate(copy, { scale: [1.25, 1], duration: 900, ease: 'out(3)' });
// Older photos shrink as they fade.
setTimeout(function () {
anime.animate(item, { scale: 0.75, y: '+=14', duration: 500, ease: 'in(2)' });
}, life);
}
function moveTo(x, y) {
if (!last) { last = { x: x, y: y }; return; }
var dist = Math.hypot(x - last.x, y - last.y);
if (dist > Math.max(w, h)) { last = { x: x, y: y }; prev = null; return; }
if (dist >= spacing) {
spawn(x, y);
last = { x: x, y: y };
}
}
function local(e) {
var b = el.getBoundingClientRect();
return [e.clientX - b.left, e.clientY - b.top];
}
var real = false, hold = 0;
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') hold = Date.now() + 2500;
else real = true;
var p = local(e);
moveTo(p[0], p[1]);
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
real = false; last = null; prev = null;
});
// Touch: a tap deals a photo where the finger lands.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
hold = Date.now() + 2500;
var p = local(e);
spawn(p[0], p[1]);
last = { x: p[0], y: p[1] };
});
if (auto) {
var t = Math.random() * 6;
var visible = false, raf = 0;
var frame = function () {
raf = 0;
if (!visible) return;
if (!real && Date.now() >= hold) {
t += 0.019;
moveTo(w * (0.5 + 0.38 * Math.sin(t * 1.2)), h * (0.5 + 0.3 * Math.sin(t * 2.3 + 0.5)));
}
raf = requestAnimationFrame(frame);
};
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !raf) raf = requestAnimationFrame(frame);
if (!visible) { last = null; prev = null; }
}).observe(el);
}
}
function init() {
if (reduced()) return;
document.querySelectorAll('[data-image-trail]').forEach(function (el) {
if (el._imt) return;
el._imt = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section class="hero" data-image-trail>
<h2>Move to explore</h2>
<img data-trail-image src="one.jpg" alt="">
<img data-trail-image src="two.jpg" alt="">
<img data-trail-image src="three.jpg" alt="">
</section>