← Scroll

Horizontal Scroll Section

The section pins in place and scrolling down slides its row of panels sideways instead, then lets the page carry on once the last panel is in view. Panels turn slightly towards you as they reach the middle.

Scroll inside ↓

01Plan
02Design
03Build
04Test
05Ship

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-horizontal-scroll to a section holding the panels, and give the panels a width (e.g. 80vw). The section pins for as long as the panels take to slide past.

Options

Add any of these attributes to the same element.

data-gapSpace between panels, in px. Default 24.
data-tiltHow far panels turn near the edges, in degrees. Default 8; 0 for none.
data-barShow a thin progress bar along the bottom while it’s pinned.

The section takes the height of the view while pinned and makes itself taller to fit the sideways scroll. Nothing around it can have overflow: hidden, as it pins with position: sticky.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Horizontal Scroll Section: the section pins in place and scrolling down
    // slides its row of panels sideways instead, then lets the page carry on
    // once the last panel is in view. Panels tilt and fade a touch as they
    // slide in and out at the edges.
    // Usage: <section data-horizontal-scroll>
    //          <div class="panel">…</div>
    //          <div class="panel">…</div>
    //          <div class="panel">…</div>
    //        </section>
    // Size the panels however you like (e.g. width: 80vw). The section takes
    // the height of the view while pinned, and grows taller to make room for
    // the sideways scroll.
    // Optional: data-gap (px between panels, default 24), data-tilt (degrees
    // panels turn at the edges, default 8; 0 for none), data-bar (show a thin
    // progress bar along the bottom).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { gap: 24, tilt: 8 };

    var css =
      '.hzs{position:relative;padding-top:0;padding-bottom:0}' +
      '.hzs-pin{position:sticky;top:0;overflow:hidden;display:flex;align-items:center}' +
      '.hzs-track{display:flex;align-items:stretch;will-change:transform;padding:0 var(--hzs-gap);perspective:1200px}' +
      '.hzs-track>*{flex:none}' +
      '.hzs-bar{position:absolute;left:0;bottom:0;height:3px;width:100%;background:currentColor;transform-origin:0 50%;transform:scaleX(0);opacity:.5}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    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') && p.scrollHeight > p.clientHeight) return p;
      }
      return null;
    }

    function setup(el, reduced) {
      var d = el.dataset;
      var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
      var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
      // Find the scrolling parent before the section changes height.
      var box = scroller(el);
      var pin = document.createElement('div');
      var track = document.createElement('div');
      pin.className = 'hzs-pin';
      track.className = 'hzs-track';
      track.style.setProperty('--hzs-gap', gap + 'px');
      track.style.gap = gap + 'px';
      var panels = Array.prototype.slice.call(el.children);
      panels.forEach(function (p) { track.appendChild(p); });
      pin.appendChild(track);
      el.appendChild(pin);
      el.classList.add('hzs');
      var bar = null;
      if (d.bar !== undefined && d.bar !== 'false') {
        bar = document.createElement('div');
        bar.className = 'hzs-bar';
        pin.appendChild(bar);
      }

      var viewH = 0, travel = 0, queued = false;
      function measure() {
        viewH = box ? box.clientHeight : window.innerHeight;
        pin.style.height = viewH + 'px';
        travel = Math.max(0, track.scrollWidth - pin.clientWidth);
        // Every px of sideways travel takes a px of scrolling.
        el.style.height = viewH + travel + 'px';
      }

      function update() {
        queued = false;
        var viewTop = box ? box.getBoundingClientRect().top + box.clientTop : 0;
        var p = travel ? Math.max(0, Math.min(1, (viewTop - el.getBoundingClientRect().top) / travel)) : 0;
        anime.utils.set(track, { x: -p * travel });
        if (bar) bar.style.transform = 'scaleX(' + p + ')';
        if (!tilt) return;
        // Panels turn away a little near the edges of the view, and face you
        // in the middle.
        var w = pin.clientWidth, left = pin.getBoundingClientRect().left;
        panels.forEach(function (panel) {
          var r = panel.getBoundingClientRect();
          var c = (r.left + r.width / 2 - left - w / 2) / (w / 2 + r.width / 2);
          c = Math.max(-1, Math.min(1, c));
          anime.utils.set(panel, { rotateY: -c * tilt, scale: 1 - Math.abs(c) * 0.06, opacity: 1 - Math.abs(c) * 0.35 });
        });
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }
      function onResize() { measure(); onScroll(); }
      measure();
      if (reduced) tilt = 0;
      update();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onResize);
      if (window.ResizeObserver) new ResizeObserver(onResize).observe(track);
    }

    function init() {
      // Reduced motion still scrolls the panels sideways (that's what scrolling
      // does here), but without the tilt.
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-horizontal-scroll]').forEach(function (el) {
        if (el._hzs) return;
        el._hzs = true;
        setup(el, reduced);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-horizontal-scroll>
  <div class="panel" style="width: 80vw">…</div>
  <div class="panel" style="width: 80vw">…</div>
  <div class="panel" style="width: 80vw">…</div>
</section>