Zoom Parallax Cluster
A cluster of images pins to the screen and zooms as you scroll. The one in the middle grows until it fills the view while the ones around it grow faster, each at its own rate, and fly out past the edges. Scroll back and they gather again.
Scroll inside ↓
How to use it
- Paste the snippet just before
</head>. - Add
data-zoom-parallax-clusterto a section whose first child is your cluster of images (any layout). - Mark the middle image with
data-center. Anything inside it markeddata-captionfades in as it fills the view.
Options
Add any of these attributes to the same element.
data-length | How many screen heights of scrolling the zoom takes while pinned. Default 1.5. |
data-fill | How much of the view the middle image covers at the end. Default 1 (all of it). |
data-rate (on an image) | How much faster than the middle one it grows. Default: further out grows faster, 1.3 to 2. |
It pins with position: sticky, so nothing around it can have overflow: hidden. With reduced motion the cluster simply shows as it is. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Zoom Parallax Cluster: a cluster of images pins to the screen and zooms
// as you scroll. The central one grows until it fills the view while the
// ones around it scale up faster, each at its own rate, and fly out past
// the edges. Scroll back up and they gather again.
// Usage: <section data-zoom-parallax-cluster>
// <div class="my-cluster"> (the cluster: any layout)
// <img src="a.jpg" alt="…">
// <figure data-center>…</figure> (the one that fills the view)
// <img src="c.jpg" alt="…" data-rate="1.6">
// </div>
// </section>
// Each image's data-rate sets how much faster than the centre it grows
// (default: further out grows faster, 1.3 to 2). Anything inside the
// centre image marked data-caption fades in as it fills the view.
// Optional: data-length (how many screen heights of scrolling the zoom
// takes while pinned, default 1.5), data-fill (how much of the view the
// centre image covers at the end, 1 is all of it, default 1).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { length: 1.5, fill: 1 };
var css =
'.zpc{position:relative}' +
'.zpc-pin{position:sticky;top:0;display:grid;place-items:center;overflow:hidden}' +
'.zpc-img{will-change:transform}' +
'.zpc-center{position:relative;z-index:1}';
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, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if (o === 'auto' || o === 'scroll') return p;
}
return null;
}
function ease(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function setup(el) {
var d = el.dataset;
var length = Math.max(0.2, num(d.length, DEFAULTS.length));
var fill = Math.max(0.2, num(d.fill, DEFAULTS.fill));
var cluster = el.firstElementChild;
if (!cluster) return;
var center = cluster.querySelector('[data-center]') || cluster.firstElementChild;
var box = scroller(el);
var captions = center.querySelectorAll('[data-caption]');
var others = Array.prototype.filter.call(cluster.children, function (c) { return c !== center; });
var pin = document.createElement('div');
pin.className = 'zpc-pin';
pin.appendChild(cluster);
el.appendChild(pin);
el.classList.add('zpc');
center.classList.add('zpc-img', 'zpc-center');
others.forEach(function (c) { c.classList.add('zpc-img'); });
var viewH = 0, cover = 1, dx = 0, dy = 0, queued = false, last = -1;
// Corners keep their size on screen as the images grow (and the centre squares off as it fills).
var radius = parseFloat(getComputedStyle(center).borderTopLeftRadius) || 0;
var parts = [];
function measure() {
viewH = box ? box.clientHeight : window.innerHeight;
var viewW = pin.clientWidth || el.clientWidth;
pin.style.height = viewH + 'px';
el.style.height = viewH * (1 + length) + 'px';
center.style.transform = '';
center.style.borderRadius = '';
radius = parseFloat(getComputedStyle(center).borderTopLeftRadius) || 0;
others.forEach(function (c) { c.style.transform = ''; });
var pr = pin.getBoundingClientRect();
var cr = center.getBoundingClientRect();
var cx = cr.left + cr.width / 2, cy = cr.top + cr.height / 2;
cover = Math.max(viewW / (cr.width || 1), viewH / (cr.height || 1)) * fill;
// Bring the centre image to the middle of the view as it grows.
dx = pr.left + viewW / 2 - cx;
dy = pr.top + viewH / 2 - cy;
var maxD = 1;
parts = others.map(function (c) {
var r = c.getBoundingClientRect();
var ox = r.left - cx, oy = r.top - cy;
var dist = Math.sqrt(Math.pow(r.left + r.width / 2 - cx, 2) + Math.pow(r.top + r.height / 2 - cy, 2));
maxD = Math.max(maxD, dist);
// Scale about the cluster's centre, so growing pushes the image outwards.
c.style.transformOrigin = (-ox).toFixed(1) + 'px ' + (-oy).toFixed(1) + 'px';
c.style.borderRadius = '';
return { el: c, d: dist, rate: num(c.dataset.rate, NaN), r: parseFloat(getComputedStyle(c).borderTopLeftRadius) || 0 };
});
parts.forEach(function (p) { if (isNaN(p.rate)) p.rate = 1.3 + 0.7 * (p.d / maxD); });
last = -1;
}
function update() {
queued = false;
var top = box ? box.getBoundingClientRect().top + box.clientTop : 0;
var y = el.getBoundingClientRect().top - top;
var p = Math.max(0, Math.min(1, -y / (viewH * length)));
if (Math.abs(p - last) < 0.0005) return;
last = p;
var e = ease(p);
var s = 1 + (cover - 1) * e;
var tx = (dx * e).toFixed(2) + 'px,' + (dy * e).toFixed(2) + 'px';
center.style.transform = e <= 0 ? '' : 'translate3d(' + tx + ',0) scale(' + s.toFixed(4) + ')';
center.style.borderRadius = e <= 0 ? '' : (radius * (1 - e) / s).toFixed(2) + 'px';
for (var c = 0; c < captions.length; c++) {
captions[c].style.opacity = Math.max(0, Math.min(1, (p - 0.6) / 0.3)).toFixed(3);
captions[c].style.transform = 'scale(' + (1 / s).toFixed(4) + ')';
}
for (var i = 0; i < parts.length; i++) {
var ps = 1 + (cover - 1) * e * parts[i].rate;
parts[i].el.style.transform = e <= 0 ? '' : 'translate3d(' + tx + ',0) scale(' + ps.toFixed(4) + ')';
parts[i].el.style.borderRadius = e <= 0 ? '' : (parts[i].r / ps).toFixed(2) + 'px';
}
}
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
measure();
update();
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', function () { measure(); update(); });
if (window.ResizeObserver) {
var ro = new ResizeObserver(function () { measure(); update(); });
ro.observe(cluster);
if (box) ro.observe(box);
}
}
function init() {
if (reduced()) return; // no motion: the cluster simply shows as it is
document.querySelectorAll('[data-zoom-parallax-cluster]').forEach(function (el) {
if (el._zpc) return;
el._zpc = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-zoom-parallax-cluster>
<div class="cluster">
<img src="1.jpg" alt="…">
<img src="2.jpg" alt="…" data-rate="1.8">
<figure data-center>
<img src="main.jpg" alt="…">
<figcaption data-caption>Wander</figcaption>
</figure>
<img src="4.jpg" alt="…">
</div>
</section>