Scroll Corridor
The section pins to the screen and its pictures line the walls of a 3D corridor. Scrolling walks you down it: the pictures sweep past on either side, new ones loom out of the distance, and a sign waits at the far end. Scroll back to walk out again.
Scroll inside ↓
Keep goingScroll back to walk out.
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-corridorto a section holding your images (or any cards). They go on the walls in order, left then right. - Optionally add an element marked
data-end: it waits at the far end of the corridor. - Give the section a background: it’s what you see down the corridor.
Options
Add any of these attributes to the same element.
data-length | How many screen heights of scrolling the walk takes while pinned. Default 2.5. |
data-angle | How far the walls turn towards you, in degrees. 90 is a true corridor. Default 78. |
data-width | The corridor’s width as a share of the screen. Default 0.86. |
data-size | Each picture’s height as a share of the screen. Default 0.42. |
data-smooth | How quickly it catches up with the scroll, from 0 to 1. Default 0.12; 1 follows it exactly. |
It pins with position: sticky, so nothing around it can have overflow: hidden. With reduced motion the pictures simply show as you laid them out. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll Corridor: the images in a section line the walls of a 3D corridor,
// and scrolling walks you down it, the pictures sweeping past on either side.
// The section pins to the screen while you walk; scroll back to walk out again.
// Usage: <section data-scroll-corridor>
// <img src="a.jpg" alt="…"> (any elements: images, figures, cards)
// <img src="b.jpg" alt="…">
// <h2 data-end>The end</h2> (optional: waits at the far end)
// </section>
// Optional: data-length (how many screen heights of scrolling the walk
// takes, default 2.5), data-angle (how far the walls are turned towards
// you, degrees, 90 is a true corridor, default 78), data-width (corridor
// width as a share of the screen, default 0.86), data-size (picture height
// as a share of the screen, default 0.42), data-smooth (how quickly it
// catches up with the scroll, 0 to 1, default 0.12; 1 follows exactly).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { length: 2.5, angle: 78, width: 0.86, size: 0.42, smooth: 0.12 };
var css =
'.scor{position:relative}' +
'.scor-pin{position:sticky;top:0;overflow:hidden;perspective-origin:50% 50%}' +
'.scor-world{position:absolute;left:50%;top:50%;width:0;height:0;transform-style:preserve-3d}' +
'.scor-world>*{position:absolute;margin:0;box-sizing:border-box;backface-visibility:hidden;will-change:transform,opacity}' +
'.scor-world>img{object-fit:cover}';
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 length = Math.max(0.5, num(d.length, DEFAULTS.length));
var angle = Math.max(0, Math.min(90, num(d.angle, DEFAULTS.angle)));
var widthK = Math.max(0.3, Math.min(1.5, num(d.width, DEFAULTS.width)));
var sizeK = Math.max(0.1, Math.min(0.9, num(d.size, DEFAULTS.size)));
var smooth = Math.max(0.01, Math.min(1, num(d.smooth, DEFAULTS.smooth)));
var box = scroller(el);
var items = Array.prototype.slice.call(el.children);
var end = el.querySelector(':scope > [data-end]');
var walls = items.filter(function (c) { return c !== end; });
if (!walls.length) return;
var pin = document.createElement('div');
pin.className = 'scor-pin';
var world = document.createElement('div');
world.className = 'scor-world';
items.forEach(function (c) { world.appendChild(c); });
pin.appendChild(world);
el.appendChild(pin);
el.classList.add('scor');
var viewH = 0, viewW = 0, persp = 0, gap = 0, depth = 0, startZ = 0;
var places = [];
var target = 0, current = 0, running = false, visible = true;
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';
persp = Math.max(viewW, viewH) * 0.9;
pin.style.perspective = persp + 'px';
var h = viewH * sizeK;
var w = Math.min(h * 1.4, viewW * 0.9);
var half = (viewW * widthK) / 2;
gap = w * 0.75;
startZ = persp * 0.25;
places = walls.map(function (c, i) {
var left = i % 2 === 0;
var z = startZ + Math.floor(i / 2) * gap * 2 + (left ? 0 : gap);
c.style.width = w + 'px';
c.style.height = h + 'px';
c.style.left = -w / 2 + 'px';
c.style.top = -h / 2 + 'px';
// The picture's centre sits on the wall, its face turned in towards the walkway.
return { el: c, x: (left ? -1 : 1) * half, z: z, rot: (left ? 1 : -1) * angle };
});
var lastZ = places.length ? places[places.length - 1].z : 0;
depth = lastZ + gap * 1.2;
if (end) {
end.style.width = Math.min(viewW * 0.8, 520) + 'px';
end.style.left = -Math.min(viewW * 0.8, 520) / 2 + 'px';
end.style.top = -end.offsetHeight / 2 + 'px';
}
readScroll();
current = target;
draw();
}
function readScroll() {
var top = box ? box.getBoundingClientRect().top + box.clientTop : 0;
var y = el.getBoundingClientRect().top - top;
target = Math.max(0, Math.min(1, -y / (viewH * length)));
}
function draw() {
// The camera walks from the entrance to just short of the far end.
var cam = current * (depth - persp * 0.55);
var fadeFar = persp * 3.2;
for (var i = 0; i < places.length; i++) {
var p = places[i];
var rel = p.z - cam; // how far ahead of the camera the picture is
var o = 1;
if (rel > fadeFar * 0.6) o = Math.max(0, 1 - (rel - fadeFar * 0.6) / (fadeFar * 0.4));
if (rel < persp * 0.15) o = Math.max(0, (rel + persp * 0.35) / (persp * 0.5));
p.el.style.opacity = o.toFixed(3);
p.el.style.visibility = o <= 0.001 ? 'hidden' : '';
p.el.style.transform = 'translate3d(' + p.x.toFixed(1) + 'px,0,' + (-rel).toFixed(1) + 'px) rotateY(' + p.rot + 'deg)';
}
if (end) {
var er = depth - cam;
end.style.transform = 'translate3d(0,0,' + (-er).toFixed(1) + 'px)';
end.style.opacity = Math.max(0, Math.min(1, 1 - (er - persp * 1.5) / (persp * 2))).toFixed(3);
}
}
function tick() {
current += (target - current) * smooth;
if (Math.abs(target - current) < 0.0004) current = target;
draw();
if (current !== target && visible) requestAnimationFrame(tick);
else running = false;
}
function onScroll() {
readScroll();
if (!running && visible) {
running = true;
requestAnimationFrame(tick);
}
}
measure();
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', measure);
if (window.ResizeObserver) {
var ro = new ResizeObserver(measure);
ro.observe(pin);
if (box) ro.observe(box);
}
new IntersectionObserver(function (es) {
visible = es[0].isIntersecting;
if (visible) onScroll();
}).observe(el);
}
function init() {
if (reduced()) return; // no motion: the pictures simply show as laid out
document.querySelectorAll('[data-scroll-corridor]').forEach(function (el) {
if (el._scor) return;
el._scor = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section class="corridor" data-scroll-corridor>
<img src="1.jpg" alt="…">
<img src="2.jpg" alt="…">
<img src="3.jpg" alt="…">
<img src="4.jpg" alt="…">
<h2 data-end>Keep going</h2>
</section>