← Scroll

Split Reveal on Scroll

An image pins to the screen, splits down the middle, and its two halves slide apart as you scroll, swinging out a little, to reveal the next section waiting behind it. Scroll back and they close up again.

Scroll inside ↓

Open up
Welcome inThis section was waiting behind the picture.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-split-reveal-on-scroll to a section. Its first child is the cover: an image, a video, or a block with a title over a picture. It fills the screen, cropped to cover it.
  3. Put the section to reveal inside it too, marked data-next.

Options

Add any of these attributes to the same element.

data-lengthHow many screen heights of scrolling the split takes while pinned. Default 1.2.
data-split="horizontal"Split across the middle instead, the halves sliding up and down.
data-tiltHow far the halves swing out as they part, in degrees. Default 4; 0 for none.

It pins with position: sticky, so nothing around it can have overflow: hidden. With reduced motion the cover and the next section simply stack. 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 () {
    // Split Reveal on Scroll: an image pins to the screen, splits down the
    // middle, and its two halves slide apart as you scroll, uncovering the next
    // section behind it. Scroll back and the halves close up again.
    // Usage: <section data-split-reveal-on-scroll>
    //          <img src="cover.jpg" alt="…">    (the first child is the cover)
    //          <div data-next>…</div>            (what's revealed behind it)
    //        </section>
    // The cover can be any element (an image, a video, a block with a title
    // over a picture); it fills the screen, cropped to cover it.
    // Optional: data-length (how many screen heights of scrolling the split
    // takes while pinned, default 1.2), data-split="horizontal" (split across
    // the middle instead, halves sliding up and down), data-tilt (the halves
    // swing out by this many degrees as they part, default 4; 0 for none).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { length: 1.2, tilt: 4 };

    var css =
      '.srs{position:relative}' +
      '.srs-pin{position:sticky;top:0;overflow:hidden}' +
      '.srs-next{position:absolute!important;inset:0;margin:0!important;transform-origin:50% 50%}' +
      '.srs-half{position:absolute;inset:0;overflow:hidden;will-change:transform}' +
      '.srs-half>:not(.srs-edge){position:absolute!important;inset:0;width:100%!important;height:100%!important;max-width:none!important;margin:0!important;border-radius:0!important;object-fit:cover}' +
      '.srs-edge{position:absolute;pointer-events:none;opacity:0}';
    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 tilt = num(d.tilt, DEFAULTS.tilt);
      var across = d.split === 'horizontal';
      var next = el.querySelector(':scope > [data-next]');
      var cover = null;
      for (var c = el.firstElementChild; c; c = c.nextElementSibling) {
        if (c !== next) { cover = c; break; }
      }
      if (!cover || !next) return;
      var box = scroller(el);

      var pin = document.createElement('div');
      pin.className = 'srs-pin';
      var a = document.createElement('div'), b = document.createElement('div');
      a.className = 'srs-half';
      b.className = 'srs-half';
      b.setAttribute('aria-hidden', 'true');
      var twin = cover.cloneNode(true);
      twin.removeAttribute('id');
      twin.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
      a.appendChild(cover);
      b.appendChild(twin);
      // A soft shadow along each torn edge, so the halves read as two pieces.
      var edgeA = document.createElement('div'), edgeB = document.createElement('div');
      edgeA.className = edgeB.className = 'srs-edge';
      a.appendChild(edgeA);
      b.appendChild(edgeB);
      if (across) {
        a.style.clipPath = 'inset(0 0 50% 0)';
        b.style.clipPath = 'inset(50% 0 0 0)';
        edgeA.style.cssText = 'left:0;right:0;top:calc(50% - 26px);height:26px;background:linear-gradient(to top,rgba(0,0,0,.35),transparent)';
        edgeB.style.cssText = 'left:0;right:0;top:50%;height:26px;background:linear-gradient(to bottom,rgba(0,0,0,.35),transparent)';
      } else {
        a.style.clipPath = 'inset(0 50% 0 0)';
        b.style.clipPath = 'inset(0 0 0 50%)';
        edgeA.style.cssText = 'top:0;bottom:0;left:calc(50% - 26px);width:26px;background:linear-gradient(to left,rgba(0,0,0,.35),transparent)';
        edgeB.style.cssText = 'top:0;bottom:0;left:50%;width:26px;background:linear-gradient(to right,rgba(0,0,0,.35),transparent)';
      }
      a.style.transformOrigin = across ? '50% 0' : '0 50%';
      b.style.transformOrigin = across ? '50% 100%' : '100% 50%';
      next.classList.add('srs-next');
      pin.appendChild(next);
      pin.appendChild(a);
      pin.appendChild(b);
      el.appendChild(pin);
      el.classList.add('srs');

      var viewH = 0, queued = false, last = -1;

      function measure() {
        viewH = box ? box.clientHeight : window.innerHeight;
        pin.style.height = viewH + 'px';
        el.style.height = viewH * (1 + length) + 'px';
        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 shift = (e * 52).toFixed(2) + '%';
        var r = (e * tilt).toFixed(2);
        if (across) {
          a.style.transform = 'translate3d(0,-' + shift + ',0) rotate(' + (-r) + 'deg)';
          b.style.transform = 'translate3d(0,' + shift + ',0) rotate(' + (-r) + 'deg)';
        } else {
          a.style.transform = 'translate3d(-' + shift + ',0,0) rotate(' + (-r) + 'deg)';
          b.style.transform = 'translate3d(' + shift + ',0,0) rotate(' + r + 'deg)';
        }
        var edge = Math.min(1, e * 6).toFixed(2);
        edgeA.style.opacity = edgeB.style.opacity = e >= 1 ? 0 : edge;
        next.style.transform = 'scale(' + (1.12 - 0.12 * e).toFixed(4) + ')';
        next.style.filter = e >= 1 ? '' : 'brightness(' + (0.6 + 0.4 * e).toFixed(3) + ')';
        var hide = e >= 1 ? 'hidden' : '';
        a.style.visibility = b.style.visibility = hide;
      }

      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 && box) new ResizeObserver(function () { measure(); update(); }).observe(box);
    }

    function init() {
      if (reduced()) return;   // no motion: the cover and the next section simply stack
      document.querySelectorAll('[data-split-reveal-on-scroll]').forEach(function (el) {
        if (el._srs) return;
        el._srs = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-split-reveal-on-scroll>
  <img src="cover.jpg" alt="A mountain lake">
  <div data-next>
    <h2>Welcome in</h2>
  </div>
</section>