← Scroll

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

  1. Paste the snippet just before </head>.
  2. Add data-scroll-corridor to a section holding your images (or any cards). They go on the walls in order, left then right.
  3. Optionally add an element marked data-end: it waits at the far end of the corridor.
  4. Give the section a background: it’s what you see down the corridor.

Options

Add any of these attributes to the same element.

data-lengthHow many screen heights of scrolling the walk takes while pinned. Default 2.5.
data-angleHow far the walls turn towards you, in degrees. 90 is a true corridor. Default 78.
data-widthThe corridor’s width as a share of the screen. Default 0.86.
data-sizeEach picture’s height as a share of the screen. Default 0.42.
data-smoothHow 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.

1 · Snippet
<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>
2 · Your HTML
<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>