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
- Paste the snippet just before
</head>. - Add
data-split-reveal-on-scrollto 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. - Put the section to reveal inside it too, marked
data-next.
Options
Add any of these attributes to the same element.
data-length | How 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-tilt | How 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.
<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><section data-split-reveal-on-scroll>
<img src="cover.jpg" alt="A mountain lake">
<div data-next>
<h2>Welcome in</h2>
</div>
</section>