Partial page-peel animation on a multi-screen page
The last notes
Website hosting
If you are choosing hosting for your project, I recommend Timeweb. Modern infrastructure, a wide range of plans from starter options to high-load VPS, and responsive tech support that actually helps instead of sending template replies. I host some of my own projects there and recommend it to my clients.
Go to site Affiliate link. The price remains the same for you.All English-language materials have been translated fully automatically using the Google service
The Task: Sticky First Two Screens on a Long Landing Page
A landing page has 13 sections. The first two should slide like full-viewport slides, with smooth transitions and navigation dots. From the third screen onward, normal native scrolling should take over. Classic pagepiling.js doesn't fit here: it "sticks" on all sections in a row, forcing you into a normalScrollElements hack or a full rewrite of the logic. The second path is cleaner: a hundred and fifty lines of custom script solve the task predictably and without extra dependencies.
The Core Idea: Two Independent Areas
The page is split into two parts. The first is a fixed overlay position: fixed that contains only the first two screens. They slide via transform: translateY. The second is normal content in the document flow, starting from the third screen. It scrolls natively, and the overlay plays no role in its life.
Switching between modes is done via a class on <body>. While the piling-locked class is present, the overlay is active and native scrolling is blocked via overflow: hidden. As soon as the class is removed, the overlay slides above the top edge of the viewport, and the page starts scrolling normally.
Markup for a Bitrix Template
In Bitrix, such a page is usually assembled in header.php and footer.php of the site template. The first two screens are separate sections that don't belong to any component and live directly in header.php. Everything after the second screen comes from components like news.list, catalog.section, and similar.
The overlay markup and navigation dots are output in header.php right after the opening <body>. The main content is the standard template output, unchanged.
<body class="piling-locked">
<header class="header">SITE HEADER</header>
<div id="pagepiling" class="piling">
<div class="piling__track" id="pilingTrack">
<section class="js--slide" id="main">Screen 1</section>
<section class="js--slide" id="works">Screen 2</section>
</div>
</div>
<nav class="piling-nav" id="pilingNav" aria-hidden="true"></nav>
Then normal content starts. In footer.php, before the closing </body>, the script itself is included — see below.
CSS: Overlay, Track, Slides
The overlay is fixed across the whole viewport and gets its own z-index. The track shifts via transform with the same curve and duration as the overlay's own departure. Slides inside the track are exactly the viewport height.
body.piling-locked {
overflow: hidden;
overscroll-behavior-y: contain;
}
#pagepiling.piling {
position: fixed;
inset: 0;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
z-index: 10;
transition: transform 1s cubic-bezier(.77, 0, .18, 1);
will-change: transform;
}
#pagepiling.piling.piling--up {
transform: translateY(-100%);
}
.piling__track {
transition: transform 1s cubic-bezier(.77, 0, .18, 1);
will-change: transform;
}
#pagepiling.piling > .piling__track > .js--slide {
box-sizing: border-box;
height: 100vh !important;
height: 100dvh !important;
overflow: hidden;
}
A few decisions worth spelling out. height: 100dvh instead of 100vh — because on iOS Safari the dynamic address bar "eats" part of the vh, and the slide gets cut off at the bottom. box-sizing: border-box guarantees that padding inside the slide doesn't push it beyond the viewport. The overlay's z-index: 10 is lower than the header's (z-index: 100) so the header isn't covered.
Track Shift: Why Percentages, Not vh
The shift is computed as a percentage of the track's own height, not in vh. This is crucial: -100vh doesn't match 100dvh, and on mobile the slides "settle" with a shift of a few dozen pixels. A percentage of the track's own height works flawlessly in any browser.
function updateTrack(animated) {
var pct = (currentIndex * 100) / TOTAL;
if (!animated) track.style.transition = 'none';
track.style.transform = 'translate3d(0, -' + pct + '%, 0)';
if (!animated) {
requestAnimationFrame(function () {
requestAnimationFrame(function () { track.style.transition = ''; });
});
}
}
The double requestAnimationFrame during a non-animated shift is needed so the browser applies the change without a transition, and only then returns transition back. A single frame isn't enough — the styles don't have time to "stick".
Scroll Interception: wheel and touchmove
While the overlay is active, wheel and touchmove events are intercepted with preventDefault(). Without the passive: false flag, the browser ignores preventDefault() — a common mistake where hours get lost in debugging.
window.addEventListener('wheel', function (e) {
if (!pilingEnabled) return;
if (isLocked) {
e.preventDefault();
if (Date.now() < lockCooldownUntil) return;
if (isAnimating) return;
if (e.deltaY > 0) {
if (currentIndex < TOTAL - 1) goTo(currentIndex + 1);
else unlock();
} else {
if (currentIndex > 0) goTo(currentIndex - 1);
}
return;
}
if (tryReturnToPiling(e.deltaY)) e.preventDefault();
}, { passive: false });
For touchmove, a swipe threshold is additionally used — Math.abs(dy) < 40. Without it, a light touch slides the screen, and the interface starts "jittering" on every gesture. The isAnimating flag blocks parallel goTo() calls until the previous transition completes.
The Main Trap: Scroll Inertia on Return
The user holds the scroll up, flies from the third screen to the first, skipping the second. Or the opposite — the return into the overlay fires accidentally on a light "spin-down" of the wheel. The cause is inertia: after reaching scrollY === 0, the wheel or trackpad keeps "spinning up" for another second or two. Every event becomes a reason to call lock() and yank the track again.
The solution has two parts. First — an effort accumulator. The return fires not from a single event but from the sum of upward movements.
var RETURN_THRESHOLD = 250;
var RETURN_RESET_MS = 250;
function tryReturnToPiling(deltaY) {
if (!pilingEnabled) return false;
if (window.scrollY > 2) { scrollUpAccum = 0; return false; }
if (deltaY >= 0) { scrollUpAccum = 0; return false; }
scrollUpAccum += Math.abs(deltaY);
clearTimeout(scrollUpResetTimer);
scrollUpResetTimer = setTimeout(function () { scrollUpAccum = 0; }, RETURN_RESET_MS);
if (scrollUpAccum >= RETURN_THRESHOLD) lock();
return true;
}
A short "spin-down" accumulates 30–50 — doesn't fire. A deliberate gesture of 250 or more — fires. Second — a cooldown after the return: the first 700 milliseconds after lock(), any scroll events are ignored, and the inertia has time to settle. Without the cooldown, the user sees a return to the second screen and an instant skip to the first.
Smooth Departure and Return of the Overlay
You can't abruptly hide the overlay via display: none — there'll be a "click". It should slide up, opening native scrolling. The return is a mirror operation: the overlay is set above the top edge instantly, without transition, the track is already thrown to the last slide, and only then, via a double requestAnimationFrame, the overlay smoothly descends.
function lock() {
if (!pilingEnabled || isAnimating) return;
isAnimating = true;
isLocked = true;
document.body.classList.add('piling-locked');
window.scrollTo(0, 0);
currentIndex = TOTAL - 1;
updateTrack(false);
setDot(currentIndex);
syncBodyState();
piling.style.transition = 'none';
piling.classList.add('piling--up');
nav.classList.add('is-hidden');
void piling.offsetHeight;
piling.style.transition = '';
requestAnimationFrame(function () {
requestAnimationFrame(function () {
piling.classList.remove('piling--up');
nav.classList.remove('is-hidden');
});
});
setTimeout(function () {
isAnimating = false;
lockCooldownUntil = Date.now() + LOCK_COOLDOWN_MS;
resetReturnState();
}, OVERLAY_ANIM_MS);
}
Timing synchronization is critical: ANIM_MS === OVERLAY_ANIM_MS === transition in CSS. All transitions take one second with the same cubic-bezier(.77, 0, .18, 1) curve. Then sliding between the first and second screens and departing into scroll feel like a single motion.
The Header: Visible on the First Screen, Hidden on the Second
On the first screen, the header should sit over the banner. On the second, it should be absent so it doesn't cover content. This is controlled via a class on <body>. The header's height is written into the CSS variable --header-height so other blocks can account for it.
function syncHeaderHeight() {
var header = document.querySelector('header') || document.querySelector('.header');
if (!header) return;
var h = header.offsetHeight || 0;
if (document.body.classList.contains('panel')) h += 40;
document.documentElement.style.setProperty('--header-height', h + 'px');
}
syncHeaderHeight();
window.addEventListener('load', syncHeaderHeight);
window.addEventListener('resize', syncHeaderHeight);
body.piling-locked header,
body.piling-locked .header {
z-index: 200 !important;
transition: transform .45s cubic-bezier(.5, 0, .2, 1);
}
body.piling-hide-header header,
body.piling-hide-header .header {
transform: translateY(-100%);
}
The piling-hide-header class is toggled by the syncBodyState() function after each goTo(), lock(), unlock(), and enablePiling().
Disabling on Mobile
On narrow viewports, piling is harmful: swipes conflict with OS gestures, the banner shrinks, the address bar behaves unpredictably. The solution is to instantly disable piling at widths under 992 pixels. The piling--off class moves the overlay from position: fixed into the normal flow, and the whole logic falls off in one fell swoop.
#pagepiling.piling.piling--off {
position: static;
height: auto;
transform: none !important;
transition: none;
overflow: visible;
z-index: auto;
}
#pagepiling.piling.piling--off .piling__track {
transform: none !important;
transition: none;
}
#pagepiling.piling.piling--off > .piling__track > .js--slide {
height: auto !important;
min-height: 100vh;
min-height: 100dvh;
overflow: visible;
}
#pagepiling.piling.piling--off ~ .piling-nav {
display: none;
}
When the width is toggled back and forth, no "hanging" states remain: the piling-locked class is removed, the header returns, scrolling is correct. Opening on mobile directly initializes as disabled. Rotating the phone into landscape at width ≥ 992 enables piling; back disables it.
Hooking into a Bitrix Template
The script goes into /local/js/piling/piling.js next to the site template. Styles go into /local/js/piling/piling.css. Don't use direct inclusion via <script src="..."> in header.php — Bitrix includes assets in the standard way.
The first option is via Asset::addJs() and Asset::addCss() in footer.php of the site template, so the script loads after the markup:
<?php
use Bitrix\Main\Page\Asset;
Asset::addCss('/local/js/piling/piling.css');
Asset::addJs('/local/js/piling/piling.js');
?>
The second option — if the template is assembled via components and piling is needed only on the main page, include the script from the corresponding component's component_epilog.php or from the site template's script.js. In script.js, it's enough to wrap the logic in a check for #pagepiling — on other pages the script simply won't fire.
It's convenient to keep the overlay markup and navigation dots in the template's header.php rather than in a component: they belong to the whole page. If the landing is one-page, you can extract them into a separate .php file and include it.
The Full Working Script
Below is the final piling.js. You can open it in a browser on a test page and try it out by hand before integrating into a project.
(function () {
var track = document.getElementById('pilingTrack');
var piling = document.getElementById('pagepiling');
var nav = document.getElementById('pilingNav');
if (!track || !piling || !nav) return;
var slides = track.querySelectorAll(':scope > .js--slide');
var TOTAL = slides.length;
if (TOTAL < 2) return;
var BREAKPOINT = 992;
var ANIM_MS = 1000;
var OVERLAY_ANIM_MS = 1000;
var RETURN_THRESHOLD = 250;
var RETURN_RESET_MS = 250;
var LOCK_COOLDOWN_MS = 700;
var TOUCH_SWIPE_MIN = 40;
function syncHeaderHeight() {
var header = document.querySelector('header') || document.querySelector('.header');
if (!header) return;
var h = header.offsetHeight || 0;
if (document.body.classList.contains('panel')) h += 40;
document.documentElement.style.setProperty('--header-height', h + 'px');
}
syncHeaderHeight();
window.addEventListener('load', syncHeaderHeight);
window.addEventListener('resize', syncHeaderHeight);
var dots = [];
for (var i = 0; i < TOTAL; i++) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'piling-nav__dot' + (i === 0 ? ' is-active' : '');
b.dataset.index = i;
b.setAttribute('aria-label', 'Go to screen ' + (i + 1));
nav.appendChild(b);
dots.push(b);
}
var currentIndex = 0;
var isLocked = true;
var isAnimating = false;
var touchStartY = 0;
var scrollUpAccum = 0;
var scrollUpResetTimer = null;
var lockCooldownUntil = 0;
var pilingEnabled = window.innerWidth >= BREAKPOINT;
function setDot(i) {
for (var k = 0; k < dots.length; k++) {
dots[k].classList.toggle('is-active', k === i);
}
}
function syncBodyState() {
document.body.classList.toggle(
'piling-hide-header',
pilingEnabled && isLocked && currentIndex > 0
);
}
function updateTrack(animated) {
var pct = (currentIndex * 100) / TOTAL;
if (!animated) track.style.transition = 'none';
track.style.transform = 'translate3d(0, -' + pct + '%, 0)';
if (!animated) {
requestAnimationFrame(function () {
requestAnimationFrame(function () { track.style.transition = ''; });
});
}
}
function resetReturnState() {
scrollUpAccum = 0;
clearTimeout(scrollUpResetTimer);
}
function enablePiling() {
if (pilingEnabled) return;
pilingEnabled = true;
currentIndex = 0;
isLocked = true;
isAnimating = false;
updateTrack(false);
setDot(0);
window.scrollTo(0, 0);
piling.classList.remove('piling--off', 'piling--up');
document.body.classList.add('piling-locked');
nav.classList.remove('is-hidden');
syncBodyState();
}
function disablePiling() {
if (!pilingEnabled) return;
pilingEnabled = false;
isLocked = false;
isAnimating = false;
piling.classList.add('piling--off');
piling.classList.remove('piling--up');
document.body.classList.remove('piling-locked', 'piling-hide-header');
nav.classList.add('is-hidden');
resetReturnState();
}
function checkBreakpoint() {
var shouldEnable = window.innerWidth >= BREAKPOINT;
if (shouldEnable && !pilingEnabled) enablePiling();
else if (!shouldEnable && pilingEnabled) disablePiling();
}
function goTo(index) {
if (!pilingEnabled) return;
if (isAnimating || index === currentIndex || index < 0 || index >= TOTAL) return;
isAnimating = true;
currentIndex = index;
updateTrack(true);
setDot(index);
syncBodyState();
setTimeout(function () { isAnimating = false; }, ANIM_MS);
}
function unlock() {
if (!pilingEnabled || isAnimating) return;
isAnimating = true;
document.body.classList.remove('piling-locked');
piling.classList.add('piling--up');
nav.classList.add('is-hidden');
setTimeout(function () {
isLocked = false;
isAnimating = false;
document.body.classList.remove('piling-hide-header');
resetReturnState();
}, OVERLAY_ANIM_MS);
}
function lock() {
if (!pilingEnabled || isAnimating) return;
isAnimating = true;
isLocked = true;
document.body.classList.add('piling-locked');
window.scrollTo(0, 0);
currentIndex = TOTAL - 1;
updateTrack(false);
setDot(currentIndex);
syncBodyState();
piling.style.transition = 'none';
piling.classList.add('piling--up');
nav.classList.add('is-hidden');
void piling.offsetHeight;
piling.style.transition = '';
requestAnimationFrame(function () {
requestAnimationFrame(function () {
piling.classList.remove('piling--up');
nav.classList.remove('is-hidden');
});
});
setTimeout(function () {
isAnimating = false;
lockCooldownUntil = Date.now() + LOCK_COOLDOWN_MS;
resetReturnState();
}, OVERLAY_ANIM_MS);
}
function tryReturnToPiling(deltaY) {
if (!pilingEnabled) return false;
if (window.scrollY > 2) { scrollUpAccum = 0; return false; }
if (deltaY >= 0) { scrollUpAccum = 0; return false; }
scrollUpAccum += Math.abs(deltaY);
clearTimeout(scrollUpResetTimer);
scrollUpResetTimer = setTimeout(function () { scrollUpAccum = 0; }, RETURN_RESET_MS);
if (scrollUpAccum >= RETURN_THRESHOLD) lock();
return true;
}
if (pilingEnabled) {
document.body.classList.add('piling-locked');
piling.classList.remove('piling--off');
nav.classList.remove('is-hidden');
syncBodyState();
} else {
piling.classList.add('piling--off');
document.body.classList.remove('piling-locked');
nav.classList.add('is-hidden');
}
var resizeTimer = null;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(checkBreakpoint, 100);
});
window.addEventListener('wheel', function (e) {
if (!pilingEnabled) return;
if (isLocked) {
e.preventDefault();
if (Date.now() < lockCooldownUntil) return;
if (isAnimating) return;
if (e.deltaY > 0) {
if (currentIndex < TOTAL - 1) goTo(currentIndex + 1);
else unlock();
} else {
if (currentIndex > 0) goTo(currentIndex - 1);
}
return;
}
if (tryReturnToPiling(e.deltaY)) e.preventDefault();
}, { passive: false });
window.addEventListener('touchstart', function (e) {
touchStartY = e.touches[0].clientY;
}, { passive: true });
window.addEventListener('touchmove', function (e) {
if (!pilingEnabled) return;
var dy = touchStartY - e.touches[0].clientY;
if (isLocked) {
e.preventDefault();
if (Date.now() < lockCooldownUntil) { touchStartY = e.touches[0].clientY; return; }
if (isAnimating || Math.abs(dy) < TOUCH_SWIPE_MIN) return;
if (dy > 0) {
if (currentIndex < TOTAL - 1) goTo(currentIndex + 1);
else unlock();
} else {
if (currentIndex > 0) goTo(currentIndex - 1);
}
touchStartY = e.touches[0].clientY;
return;
}
if (window.scrollY <= 2 && dy < 0) {
scrollUpAccum += Math.abs(dy);
touchStartY = e.touches[0].clientY;
clearTimeout(scrollUpResetTimer);
scrollUpResetTimer = setTimeout(function () { scrollUpAccum = 0; }, RETURN_RESET_MS);
if (scrollUpAccum >= RETURN_THRESHOLD) {
e.preventDefault();
lock();
}
} else {
scrollUpAccum = 0;
}
}, { passive: false });
dots.forEach(function (dot) {
dot.addEventListener('click', function () {
if (!pilingEnabled || !isLocked) return;
goTo(parseInt(dot.dataset.index, 10));
});
});
})();
Implementation Checklist
- Mark up the overlay
#pagepilingand dots#pilingNavin the site template'sheader.php. - Place
piling.cssandpiling.jsin/local/js/piling/. - Include the assets via
Asset::addCss()andAsset::addJs()infooter.php. - Verify that
--header-heightis set on load and on resize. - Test on real devices: a Mac trackpad, a Windows wheel, and an Android swipe behave differently — thresholds will need calibration.
- Make sure piling disables on mobile — width under 992 pixels and the
piling--offclass on the overlay.
What Can Be Improved Further
Parallax inside the slide — a slight translateY of the background during sliding adds a sense of depth. Text appearance — opacity and translateY for the heading inside goTo(). Keyboard navigation — a keydown handler for arrows and PageUp/PageDown, needed for accessibility. Timing separation — if the standard second feels long when departing into scroll, you can split ANIM_MS and OVERLAY_ANIM_MS, but then you'll need to rewrite the transition on #pagepiling separately from .piling__track.
Comments