Частичная анимация pagepill на многоэкранной странице
Последние записи
Хостинг для сайта
Если выбираете хостинг под проект — советую Timeweb. Современная инфраструктура, широкий выбор тарифов от стартовых до VPS под нагрузку, отзывчивая техподдержка, которая отвечает по существу, а не отписками. Я сам держу на нём часть своих проектов и рекомендую клиентам.
Перейти Партнёрская ссылка. Для вас цена не меняется.Задача: залипающие первые два экрана на длинном лендинге
На лендинге 13 секций. Первые две должны листаться как слайды — по одной на весь вьюпорт, с плавным переходом и точками навигации. С третьего экрана должен работать обычный нативный скролл. Классический pagepiling.js здесь не подходит: он «залипает» на всех секциях подряд, и приходится либо идти на хак с normalScrollElements, либо переписывать логику своими руками. Второй путь чище: полторы сотни строк собственного скрипта решают задачу предсказуемо и без лишних зависимостей.
Ключевая идея: две независимые области
Страница делится на две части. Первая — фиксированный оверлей position: fixed, внутри которого лежат только первые два экрана. Они перелистываются через transform: translateY. Вторая — обычный контент в потоке документа, начиная с третьего экрана. Он скроллится нативно, и оверлей в его жизни не участвует.
Переключение между режимами — через класс на <body>. Пока класс piling-locked висит, оверлей активен, а нативный скролл заблокирован через overflow: hidden. Как только класс снимается, оверлей уезжает за верхнюю границу экрана, и страница начинает скроллиться как обычно.
Разметка для шаблона Bitrix
В Bitrix такая страница обычно собирается в header.php и footer.php шаблона сайта. Первые два экрана — это отдельные секции, которые по смыслу не относятся ни к одному компоненту и живут прямо в header.php. Всё, что после второго экрана, идёт из компонентов news.list, catalog.section и подобных.
Разметка оверлея и точек навигации выводится в header.php сразу после открытия <body>. Основной контент — стандартный вывод шаблона, без изменений.
<body class="piling-locked">
<header class="header">ШАПКА САЙТА</header>
<div id="pagepiling" class="piling">
<div class="piling__track" id="pilingTrack">
<section class="js--slide" id="main">Экран 1</section>
<section class="js--slide" id="works">Экран 2</section>
</div>
</div>
<nav class="piling-nav" id="pilingNav" aria-hidden="true"></nav>
Дальше начинается обычный контент. В footer.php перед закрытием </body> подключается сам скрипт — об этом ниже.
CSS: оверлей, трек, слайды
Оверлей фиксируется на весь вьюпорт и получает собственный z-index. Трек сдвигается через transform с той же кривой и той же длительностью, что и уезд самого оверлея. Слайды внутри трека — ровно высота вьюпорта.
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;
}
Несколько решений, которые стоит проговорить отдельно. height: 100dvh вместо 100vh — потому что на iOS Safari динамическая адресная строка «съедает» часть vh, и слайд обрезается снизу. box-sizing: border-box гарантирует, что padding внутри слайда не вытолкнет его за границы вьюпорта. z-index: 10 у оверлея меньше, чем у шапки (z-index: 100), чтобы шапка не перекрывалась.
Сдвиг трека: почему в процентах, а не в vh
Сдвиг считается в процентах от высоты трека, а не в vh. Это принципиально: -100vh не совпадает с 100dvh, и на мобильных слайды «садятся» со сдвигом на несколько десятков пикселей. Процент от собственной высоты трека работает безошибочно в любом браузере.
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 = ''; });
});
}
}
Двойной requestAnimationFrame при неанимированном сдвиге нужен, чтобы браузер успел применить изменения без перехода и только потом вернул transition назад. Одиночного кадра не хватает — стили не успевают «прилипнуть».
Перехват скролла: wheel и touchmove
Пока оверлей активен, события wheel и touchmove перехватываются с preventDefault(). Без флага passive: false браузер игнорирует preventDefault() — это частая ошибка, на которой теряются часы отладки.
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 });
Для touchmove дополнительно используется порог свайпа — Math.abs(dy) < 40. Без него лёгкое касание листает слайд, и интерфейс начинает «дёргаться» на каждый жест. Флаг isAnimating блокирует параллельные вызовы goTo(), пока предыдущий переход не завершился.
Главная ловушка: инерция скролла при возврате
Пользователь удерживает скролл вверх, вылетает с третьего экрана на первый, пропуская второй. Или наоборот — возврат в оверлей срабатывает случайно при лёгком «доводчике» колеса. Причина — инерция: после выхода на scrollY === 0 колесо или тачпад продолжает «докручивать» вверх ещё одну-две секунды. Каждое событие становится поводом вызвать lock() и снова дёрнуть трек.
Решение из двух частей. Первое — накопитель усилия. Возврат срабатывает не от одного события, а от суммы движений вверх.
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;
}
Короткий «доводчик» набирает 30–50 — не срабатывает. Осознанный жест на 250 и больше — срабатывает. Второе — кулдаун после возврата: первые 700 миллисекунд после lock() любые события скролла игнорируются, инерция успевает улечься. Без кулдауна пользователь видит возврат на второй экран и мгновенный проскок на первый.
Плавный уход и возврат оверлея
Резко скрывать оверлей через display: none нельзя — будет «щелчок». Он должен уезжать вверх, открывая нативный скролл. Возврат — зеркальная операция: оверлей ставится за верхнюю границу мгновенно, без transition, трек уже переброшен на последний слайд, и только потом через двойной requestAnimationFrame оверлей плавно опускается вниз.
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);
}
Синхронизация таймингов критична: ANIM_MS === OVERLAY_ANIM_MS === transition в CSS. Все переходы — одна секунда с одной кривой cubic-bezier(.77, 0, .18, 1). Тогда перелистывание между первым и вторым экраном и уход в скролл воспринимаются как одно и то же движение.
Шапка: видна на первом экране, скрыта на втором
На первом экране шапка должна лежать поверх баннера. На втором — отсутствовать, чтобы не закрывать контент. Управляется классом на <body>. Высота шапки при этом пишется в CSS-переменную --header-height, чтобы остальные блоки могли её учитывать.
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%);
}
Класс piling-hide-header переключается функцией syncBodyState() после каждого goTo(), lock(), unlock() и enablePiling().
Отключение на мобильных
На узких вьюпортах piling вреден: свайпы конфликтуют с жестами операционной системы, баннер сжимается, адресная строка ведёт себя непредсказуемо. Решение — мгновенно выключать piling при ширине меньше 992 пикселей. Класс piling--off переводит оверлей из position: fixed в обычный поток, и вся логика отваливается одним махом.
#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;
}
При переключении ширины туда-обратно не остаётся «подвисших» состояний: класс piling-locked снимается, шапка возвращается, скролл корректный. Открытие сразу на мобильном инициализируется уже выключенным. Поворот телефона в ландшафт при ширине ≥ 992 включает piling, обратно — выключает.
Подключение в шаблоне Bitrix
Скрипт кладётся в /local/js/piling/piling.js рядом с шаблоном сайта. Стили — в /local/js/piling/piling.css. Прямое подключение через <script src="..."> в header.php использовать не нужно — Bitrix подключает ассеты штатным образом.
Вариант первый — через Asset::addJs() и Asset::addCss() в footer.php шаблона сайта, чтобы скрипт подгружался после разметки:
<?php
use Bitrix\Main\Page\Asset;
Asset::addCss('/local/js/piling/piling.css');
Asset::addJs('/local/js/piling/piling.js');
?>
Вариант второй — если шаблон собирается через компоненты, а piling нужен только на главной, подключайте скрипт из component_epilog.php соответствующего компонента или из script.js шаблона сайта. В script.js достаточно обернуть логику в проверку наличия #pagepiling — на остальных страницах скрипт просто не сработает.
Разметку оверлея и точек навигации удобно хранить в header.php шаблона, а не в компоненте: она относится ко всей странице целиком. Если лендинг одностраничный — можно вынести в отдельный .php-файл и подключать через include.
Полный рабочий скрипт
Ниже — итоговый piling.js. Его можно открыть в браузере на тестовой странице и потрогать руками до интеграции в проект.
(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', 'Перейти на экран ' + (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));
});
});
})();
Ниже — итоговый piling.css.
body.piling-locked {
overflow: hidden;
overscroll-behavior-y: contain;
}
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%);
}
#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;
}
.piling-nav {
position: fixed;
top: 50%;
right: 30px;
transform: translateY(-50%);
z-index: 11;
display: flex;
flex-direction: column;
gap: 14px;
transition: opacity .3s ease, visibility .3s ease;
}
.piling-nav.is-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.piling-nav__dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid #000;
background: transparent;
cursor: pointer;
padding: 0;
transition: background .3s, transform .3s;
}
.piling-nav__dot.is-active {
background: #000;
transform: scale(1.2);
}
.main-content {
position: relative;
z-index: 1;
}
#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,
#pagepiling.piling.piling--off ~ .piling-nav {
display: none;
}
Ниже — итоговая разметка.
<!-- Pagepiling blocks -->
<div id="pagepiling" class="piling">
<div class="piling__track" id="pilingTrack">
<div class="js--slide" id="main">
Экран 1
</div>
<div class="js--slide" id="works">
Экран 2
</div>
</div>
</div>
<!-- Навигационные точки -->
<nav class="piling-nav" id="pilingNav" aria-hidden="true"></nav>
<!-- Стандартный контент -->
<div class="main-content" id="mainContent">
</div>
Чек-лист внедрения
- Разметить оверлей
#pagepilingи точки#pilingNavвheader.phpшаблона сайта. - Положить
piling.cssиpiling.jsв/local/js/piling/. - Подключить ассеты через
Asset::addCss()иAsset::addJs()вfooter.php. - Проверить, что
--header-heightустанавливается при загрузке и при ресайзе. - Проверить работу на реальных устройствах: тачпад Mac, колесо Windows и свайп Android ведут себя по-разному, пороги придётся калибровать.
- Убедиться, что на мобильных piling отключается — ширина меньше 992 пикселей и класс
piling--offна оверлее.
Что можно доработать дальше
Параллакс внутри слайда — лёгкий translateY фона при перелистывании даёт ощущение глубины. Появление текста — opacity и translateY для заголовка внутри goTo(). Навигация с клавиатуры — обработчик keydown для стрелок и PageUp/PageDown, это нужно для доступности. Разделение таймингов — если стандартная секунда кажется долгой при уходе в скролл, можно развести ANIM_MS и OVERLAY_ANIM_MS, но тогда придётся переписать transition у #pagepiling отдельно от .piling__track.
Комментарии