Opposite Column Parallax
A gallery whose columns of pictures glide in opposite directions as you scroll: the outer columns rise while the middle one sinks. Pin it and the gallery holds still in the middle of the view while the columns slide past each other.
Scroll inside ↓
How to use it
- Paste the snippet just before
</head>. - Add
data-opposite-column-parallaxto the gallery and lay it out as columns: each direct child is a column of images. - Give the columns more pictures than fit and a fixed height to the gallery: it clips them, so their ends never come into view.
Options
Add any of these attributes to the same element.
data-distance | How far each column travels, as a share of the gallery’s height. Default 0.4. |
data-pin | Hold the gallery in the middle of the view for this many screen heights of scrolling while the columns move, e.g. 1. Off by default: the columns move as it passes. |
data-smooth | How quickly the columns catch up with the scroll, from 0 to 1. Default 0.14; 1 follows it exactly. |
Pinning uses position: sticky, so nothing around the gallery can have overflow: hidden. With reduced motion the columns sit still. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Opposite Column Parallax: a gallery whose columns of images glide in
// opposite directions as you scroll: the first, third… columns rise while
// the ones between them sink. Pin it and the gallery holds still while the
// columns slide past each other.
// Usage: <div data-opposite-column-parallax>
// <div><img …><img …><img …></div> (each child is a column)
// <div><img …><img …><img …></div>
// <div><img …><img …><img …></div>
// </div>
// Give the columns more images than fit: the gallery clips them, and they
// travel by data-distance in all.
// Optional: data-distance (how far each column travels, as a share of the
// gallery's height, default 0.4), data-pin (hold the gallery in the middle
// of the view for this many screen heights of scrolling while the columns
// move, e.g. 1), data-smooth (how quickly columns catch up with the
// scroll, 0 to 1, default 0.14; 1 follows it exactly).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { distance: 0.4, smooth: 0.14 };
var css =
'.ocp{overflow:hidden}' +
'.ocp>*{will-change:transform}' +
'.ocp-track{position:relative}' +
'.ocp-track>.ocp{position:sticky}';
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 setup(el) {
var d = el.dataset;
var distance = num(d.distance, DEFAULTS.distance);
var smooth = Math.max(0.01, Math.min(1, num(d.smooth, DEFAULTS.smooth)));
var pin = d.pin !== undefined ? Math.max(0, num(d.pin, 1)) : 0;
var cols = Array.prototype.slice.call(el.children);
var box = scroller(el);
var track = null, viewH = 0, stickTop = 0;
var target = 0, shown = 0, running = false, visible = true;
el.classList.add('ocp');
if (pin) {
track = document.createElement('div');
track.className = 'ocp-track';
el.parentNode.insertBefore(track, el);
track.appendChild(el);
}
function view() {
if (!box) return { top: 0, h: window.innerHeight };
var b = box.getBoundingClientRect();
return { top: b.top + box.clientTop, h: box.clientHeight };
}
function measure() {
viewH = view().h;
if (track) {
var h = el.offsetHeight;
stickTop = Math.max(0, (viewH - h) / 2);
el.style.top = stickTop + 'px';
track.style.height = h + viewH * pin + 'px';
}
}
// 0 as the gallery comes into view (or pins), 1 as it leaves (or unpins).
function progress() {
var v = view();
if (track) {
var r = track.getBoundingClientRect();
return (v.top + stickTop - r.top) / (r.height - el.offsetHeight || 1);
}
var e = el.getBoundingClientRect();
return (v.top + v.h - e.top) / (v.h + e.height || 1);
}
function paint() {
var shift = shown * distance * el.clientHeight;
cols.forEach(function (c, i) {
c.style.transform = 'translate3d(0,' + ((i % 2 ? 1 : -1) * shift).toFixed(2) + 'px,0)';
});
}
function tick() {
shown += (target - shown) * smooth;
if (Math.abs(target - shown) < 0.0004) shown = target;
paint();
if (shown !== target && visible) requestAnimationFrame(tick);
else running = false;
}
function onScroll() {
target = Math.max(0, Math.min(1, progress())) - 0.5;
if (running || !visible) return;
running = true;
requestAnimationFrame(tick);
}
measure();
target = shown = Math.max(0, Math.min(1, progress())) - 0.5;
paint();
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) onScroll();
}).observe(el);
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', function () { measure(); onScroll(); });
if (window.ResizeObserver) new ResizeObserver(function () { measure(); onScroll(); }).observe(box || el);
}
function init() {
if (reduced()) return; // no motion: the columns simply sit still
document.querySelectorAll('[data-opposite-column-parallax]').forEach(function (el) {
if (el._ocp) return;
el._ocp = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="gallery" data-opposite-column-parallax data-pin="1"
style="display: grid; grid-template-columns: repeat(3, 1fr);
gap: 16px; height: 80vh; align-items: center">
<div><img src="1.jpg" alt=""><img src="2.jpg" alt="">…</div>
<div><img src="5.jpg" alt=""><img src="6.jpg" alt="">…</div>
<div><img src="9.jpg" alt=""><img src="10.jpg" alt="">…</div>
</div>