Zoom-out to Grid
One image fills the screen, then as you scroll the view pulls back: it shrinks into its place in a grid while the other images slide in around it, the far ones settling last. Scroll back up and it zooms in again.
Scroll inside ↓
How to use it
- Paste the snippet just before
</head>. - Add
data-zoom-out-to-gridto a section whose first child is your grid of images (any layout). - Mark the image that starts full screen with
data-hero. Anything inside it markeddata-captionfades away as it shrinks.
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.2. |
data-radius | The hero’s corner radius once it’s in the grid, in px. Default: its own. |
data-stagger | How much later the outer images settle than the near ones, from 0 to 0.6. Default 0.15. |
It pins with position: sticky, so nothing around it can have overflow: hidden. With reduced motion the grid 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-out to Grid: one image fills the screen, then as you scroll the
// view pulls back and it shrinks into its place in a grid of other images,
// which slide in around it. Scroll back up and it zooms in again.
// Usage: <section data-zoom-out-to-grid>
// <div class="my-grid"> (the grid: any layout)
// <img src="a.jpg" alt="…">
// <figure data-hero>…</figure> (the one that starts full screen)
// <img src="c.jpg" alt="…">
// </div>
// </section>
// Anything inside the hero marked data-caption fades away as it shrinks.
// Optional: data-length (how many screen heights of scrolling the zoom
// takes while pinned, default 1.2), data-radius (the hero's corner radius
// once in the grid, px, default: its own), data-stagger (how much later
// the outer images settle than the near ones, 0 to 0.6, default 0.15).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { length: 1.2, stagger: 0.15 };
var css =
'.zog{position:relative}' +
'.zog-pin{position:sticky;top:0;display:grid;place-items:center;overflow:hidden}' +
'.zog-hero{position:relative;z-index:1;will-change:transform}' +
'.zog-tile{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; }
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 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function setup(el) {
var d = el.dataset;
var length = Math.max(0.2, num(d.length, DEFAULTS.length));
var stagger = Math.max(0, Math.min(0.6, num(d.stagger, DEFAULTS.stagger)));
var grid = el.firstElementChild;
var hero = el.querySelector('[data-hero]');
if (!grid || !hero) return;
var box = scroller(el);
var captions = hero.querySelectorAll('[data-caption]');
var radius = num(d.radius, parseFloat(getComputedStyle(hero).borderTopLeftRadius) || 0);
var tiles = Array.prototype.filter.call(grid.children, function (c) { return c !== hero; });
var pin = document.createElement('div');
pin.className = 'zog-pin';
pin.appendChild(grid);
el.appendChild(pin);
el.classList.add('zog');
hero.classList.add('zog-hero');
tiles.forEach(function (t) { t.classList.add('zog-tile'); });
var viewW = 0, viewH = 0, queued = false, last = -1;
var cover = 1, dx = 0, dy = 0, far = [];
function measure() {
viewH = box ? box.clientHeight : window.innerHeight;
viewW = pin.clientWidth || el.clientWidth;
pin.style.height = viewH + 'px';
el.style.height = viewH * (1 + length) + 'px';
// Where the hero sits in the grid, untransformed.
hero.style.transform = '';
tiles.forEach(function (t) { t.style.transform = ''; });
var pr = pin.getBoundingClientRect();
var hr = hero.getBoundingClientRect();
var hx = hr.left + hr.width / 2, hy = hr.top + hr.height / 2;
cover = Math.max(viewW / (hr.width || 1), viewH / (hr.height || 1)) * 1.02;
dx = pr.left + viewW / 2 - hx;
dy = pr.top + viewH / 2 - hy;
// Each tile's offset from the hero, and how far out it is (for the stagger).
var maxD = 1;
far = tiles.map(function (t) {
var r = t.getBoundingClientRect();
var ox = r.left + r.width / 2 - hx, oy = r.top + r.height / 2 - hy;
var dist = Math.sqrt(ox * ox + oy * oy);
maxD = Math.max(maxD, dist);
return { ox: ox, oy: oy, d: dist };
});
far.forEach(function (f) { f.k = f.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 = cover + (1 - cover) * e;
hero.style.transform = e >= 1 ? '' : 'translate3d(' + (dx * (1 - e)).toFixed(2) + 'px,' + (dy * (1 - e)).toFixed(2) + 'px,0) scale(' + s.toFixed(4) + ')';
// Keep the corners square while full screen, rounding as it lands.
hero.style.borderRadius = (radius * e / s).toFixed(2) + 'px';
// Captions keep their own size while the hero is blown up, and fade as it shrinks.
for (var c = 0; c < captions.length; c++) {
captions[c].style.opacity = Math.max(0, 1 - p * 3).toFixed(3);
captions[c].style.transform = 'scale(' + (1 / s).toFixed(4) + ')';
}
// The other tiles ride the same zoom (pushed out from the hero), the far ones a little later.
for (var i = 0; i < tiles.length; i++) {
var f = far[i];
var lp = Math.max(0, Math.min(1, (p - stagger * f.k) / (1 - stagger)));
var le = ease(lp);
var ls = cover + (1 - cover) * le;
var push = ls - 1;
tiles[i].style.transform = le >= 1 ? '' : 'translate3d(' + (dx * (1 - le) + f.ox * push).toFixed(2) + 'px,' + (dy * (1 - le) + f.oy * push).toFixed(2) + 'px,0) scale(' + ls.toFixed(4) + ')';
tiles[i].style.opacity = Math.min(1, le * 1.6).toFixed(3);
}
}
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(grid);
if (box) ro.observe(box);
}
}
function init() {
if (reduced()) return; // no motion: the grid simply shows as it is
document.querySelectorAll('[data-zoom-out-to-grid]').forEach(function (el) {
if (el._zog) return;
el._zog = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-zoom-out-to-grid>
<div class="gallery">
<img src="1.jpg" alt="…">
<img src="2.jpg" alt="…">
<figure data-hero>
<img src="hero.jpg" alt="…">
<figcaption data-caption>Explore</figcaption>
</figure>
<img src="4.jpg" alt="…">
</div>
</section>