Image Crossfade Slideshow
Images fade into one another while each one slowly zooms in, every time towards a different spot, for a calm, cinematic hero or gallery. It pauses under the pointer, while it has focus and when it’s off screen.
How to use it
- Paste the snippet just before
</head>. - Add
data-image-crossfadeto a container holding the images, and give it a size or anaspect-ratio.
Options
Add any of these attributes to the same element.
data-interval | How long each image stays. Default 3500 ms. |
data-fade | Time for each crossfade. Default 1400 ms. |
data-zoom | How far each image zooms in. Default 1.12. |
data-controls="false" | No dots or pause button. |
The dots jump to an image and the button pauses or plays it. Hidden images are hidden from screen readers too. With reduced motion there’s no zoom and no autoplay: the dots still switch images.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Image Crossfade Slideshow: images fade into one another while each one
// slowly zooms in, every time towards a different spot. It pauses under
// the pointer, while it has focus, and when off screen.
// Usage:
// <div data-image-crossfade style="aspect-ratio: 16 / 9">
// <img src="one.jpg" alt="…">
// <img src="two.jpg" alt="…">
// <img src="three.jpg" alt="…">
// </div>
// Each child is a slide. Optional: data-interval (ms each image stays,
// default 3500), data-fade (ms for a crossfade, default 1400), data-zoom
// (how far it zooms, default 1.12), data-controls="false" (no dots or
// pause button).
var DEFAULTS = { interval: 3500, fade: 1400, zoom: 1.12 };
var ORIGINS = ['50% 50%', '30% 35%', '70% 65%', '35% 70%', '68% 32%'];
var PAUSE = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>';
var PLAY = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.9l10-6.5a1 1 0 0 0 0-1.7l-10-6.5A1 1 0 0 0 8 5.5z"/></svg>';
var css =
'[data-image-crossfade]{position:relative;display:grid;overflow:hidden}' +
'[data-image-crossfade]>:not(.ixf-bar){grid-area:1/1;width:100%;height:100%;object-fit:cover;opacity:0}' +
'[data-image-crossfade]>:first-child{opacity:1}' +
'.ixf-bar{position:absolute;left:0;right:0;bottom:12px;z-index:3;display:flex;justify-content:center;align-items:center;gap:6px;pointer-events:none}' +
'.ixf-bar button{pointer-events:auto;display:grid;place-items:center;border:0;padding:0;cursor:pointer;color:#fff;background:transparent}' +
'.ixf-bar svg{pointer-events:none}' +
'.ixf-dot{width:22px;height:22px}' +
'.ixf-dot::before{content:"";width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,.55);box-shadow:0 1px 4px rgba(0,0,0,.3);transition:background .3s,transform .3s}' +
'.ixf-dot[aria-current="true"]::before{background:#fff;transform:scale(1.3)}' +
'.ixf-play{width:30px;height:30px;margin-left:6px;border-radius:50%;background:rgba(0,0,0,.35)!important}' +
'.ixf-bar button:focus-visible{outline:2px solid #fff;outline-offset:1px;border-radius:50%}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function setup(el) {
var d = el.dataset;
var slides = Array.from(el.children);
var n = slides.length;
if (n < 2) return;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var interval = +d.interval || DEFAULTS.interval;
var fade = +d.fade || DEFAULTS.fade;
var zoom = +d.zoom || DEFAULTS.zoom;
var inThumb = !!el.closest('[aria-hidden="true"]');
var current = 0, anims = [], visible = false, hovered = false, focused = false, stopped = reduced;
if (!inThumb) {
el.setAttribute('role', 'region');
el.setAttribute('aria-roledescription', 'slideshow');
}
slides.forEach(function (s, i) {
anime.utils.set(s, { opacity: i ? 0 : 1, zIndex: i ? 0 : 1 });
if (i) s.setAttribute('aria-hidden', 'true');
});
var dots = [], playBtn = null;
if (d.controls !== 'false' && !inThumb) {
var bar = document.createElement('div');
bar.className = 'ixf-bar';
slides.forEach(function (s, i) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'ixf-dot';
b.setAttribute('aria-label', 'Show image ' + (i + 1) + ' of ' + n);
b.addEventListener('click', function () { show(i, true); });
bar.appendChild(b);
dots.push(b);
});
if (!reduced) {
playBtn = document.createElement('button');
playBtn.type = 'button';
playBtn.className = 'ixf-play';
playBtn.addEventListener('click', function () { stopped = !stopped; sync(); });
bar.appendChild(playBtn);
}
el.appendChild(bar);
}
// A countdown to the next slide that pauses along with everything else.
var countdown = anime.createTimer({ duration: interval, autoplay: false, onComplete: function () { show(current + 1); } });
function running() { return visible && !hovered && !focused && !stopped && !el.closest('[hidden]'); }
function sync() {
var on = running();
anims.forEach(function (a) { if (on) a.play(); else a.pause(); });
if (on) countdown.play(); else countdown.pause();
// Only swap the icon when it changes: replacing it mid-click loses the click.
if (playBtn && playBtn._ixf !== stopped) {
playBtn._ixf = stopped;
playBtn.innerHTML = stopped ? PLAY : PAUSE;
playBtn.setAttribute('aria-label', stopped ? 'Play slideshow' : 'Pause slideshow');
}
dots.forEach(function (b, i) { b.setAttribute('aria-current', i === current ? 'true' : 'false'); });
}
// Slow zoom on the current slide, towards a different spot each time.
function drift(s, i) {
if (reduced) return;
s.style.transformOrigin = ORIGINS[i % ORIGINS.length];
anims.push(anime.animate(s, { scale: [1, zoom], duration: interval + fade * 2, ease: 'linear', autoplay: false }));
}
function show(i, user) {
i = (i + n) % n;
if (i === current) return;
var from = slides[current], to = slides[i];
current = i;
anims.forEach(function (a) { if (a !== from._ixfZoom) a.pause(); });
anims = from._ixfZoom ? [from._ixfZoom] : [];
slides.forEach(function (s) { anime.utils.set(s, { zIndex: s === to ? 2 : s === from ? 1 : 0 }); });
to.removeAttribute('aria-hidden');
from.setAttribute('aria-hidden', 'true');
anime.utils.set(to, { opacity: 0, scale: 1 });
drift(to, i);
to._ixfZoom = anims[anims.length - 1];
anims.push(anime.animate(to, {
opacity: 1,
duration: reduced ? 200 : user ? Math.min(fade, 700) : fade,
ease: 'inOut(2)',
autoplay: false,
onComplete: function () {
slides.forEach(function (s) { if (s !== to) anime.utils.set(s, { opacity: 0, scale: 1 }); });
anims = to._ixfZoom ? [to._ixfZoom] : [];
}
}));
countdown.restart();
countdown.pause();
sync();
// A chosen slide always shows, even while paused.
if (!running()) anims.forEach(function (a) { if (a !== to._ixfZoom) a.play(); });
}
drift(slides[0], 0);
slides[0]._ixfZoom = anims[0];
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { hovered = true; sync(); } });
el.addEventListener('pointerleave', function () { hovered = false; sync(); });
// Focus on a slide pauses it; focus on the dots or button leaves that to the button.
el.addEventListener('focusin', function (e) { focused = !e.target.closest('.ixf-bar'); sync(); });
el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) { focused = false; sync(); } });
new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; sync(); }, { threshold: 0.2 }).observe(el);
sync();
}
function init() {
document.querySelectorAll('[data-image-crossfade]').forEach(function (el) {
if (el._ixf) return;
el._ixf = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-image-crossfade style="aspect-ratio: 16 / 9">
<img src="one.jpg" alt="…">
<img src="two.jpg" alt="…">
<img src="three.jpg" alt="…">
</div>