Перейти к содержимому
Забрать бесплатно
Модификации выключены. Так эта страница выглядит на обычной Тильде.

Почему горизонтальный скролл по прокрутке дёргается в Тильде

28 июля 2026 7 МИН КОМАНДА MODKOD

Горизонтальную ленту, которая едет от прокрутки страницы, делают в Тильде своим кодом — и она почти всегда дёргается. Причина не в устройстве и не в количестве элементов: три ошибки в обработчике превращают одну строчку работы в шестьсот пересчётов раскладки. Все три измерены счётчиками браузера. А дальше вылезает второй сюжет: контейнер, которым обрезают вылет, отменяет липкость — и Тильда делает то же самое со всей страницей.

✓ ПРОВЕРЕНО 28 июля 2026 пересчёты раскладки и стилей у пяти реализаций одного эффекта на одинаковой прокрутке; отмена липкости пятью видами контейнера; собственное переключение overflow у Тильды на — Chromium, счётчики Performance.getMetrics протокола браузера; рантайм Zero Block из modkit/stand/vendor

Короткий ответ

Горизонтальный скролл дёргается из-за трёх вещей: сдвиг свойством left вместо transform, чтение геометрии сразу после записи и работа в каждом событии прокрутки вместо кадра. Замер: у худшей реализации 620 пересчётов раскладки и 19 мс на одной и той же прокрутке, у правильной — 1 пересчёт и 0 мс. Порядок один: слушать пассивно, считать раз в кадр, двигать трансформом.

Пять реализаций одного эффекта

Стенд: секция высотой 3000 пикселей, двадцать элементов, сдвиг пропорционально прогрессу прокрутки. Прокрутка одинаковая для всех — шестьдесят колёсиков по сорок пикселей, 2400 пикселей. Считает не глаз, а счётчики браузера через протокол: LayoutCount, RecalcStyleCount, LayoutDuration.

РеализацияПересчётов раскладкиПересчётов стилейНа раскладку, мс
transform, чтение сразу после записи206201
transform, все чтения, потом записи1310
transform, раз в кадр через rAF1310
left, все чтения, потом записи31313
left, чтение сразу после записи62062019

Одна и та же лента, одна и та же прокрутка, разница в шестьсот раз по пересчётам раскладки. Три вывода читаются прямо из таблицы.

Свойство сдвига. transform не меняет раскладку: строка «все чтения, потом записи» даёт 1 пересчёт против 31 у left. Единица — это первый, неизбежный. left заставляет пересчитывать раскладку на каждом событии прокрутки, потому что положение элемента в потоке меняется по-настоящему.

Чтение сразу после записи. Записали transform, тут же спросили getBoundingClientRect() — и браузер обязан посчитать всё немедленно, посреди обработчика, потому что иначе не сможет ответить. Двадцать элементов, тридцать одно событие: 620 пересчётов стилей вместо 31. В паре с left то же самое превращается в 620 пересчётов раскладки и 19 миллисекунд.

Кадр. Между «в обработчике, но без чтений» и «раз в кадр» счётчики совпали: 1 и 31 у обоих. Само по себе requestAnimationFrame не ускоряет — оно дисциплинирует. Как только в обработчике появляется чтение (а оно появляется: прогресс считают по getBoundingClientRect секции), кадр становится единственным местом, где чтения и записи можно развести гарантированно.

Порядок, который не дёргается

var section = document.querySelector('#rec123456 .t396__artboard');
var items = section.querySelectorAll('.js-lane-item');
var queued = false;

function paint() {
  queued = false;
  /* Сначала читаем — один раз на кадр, а не на элемент. */
  var box = section.getBoundingClientRect();
  var travel = box.height - window.innerHeight;
  var progress = travel > 0 ? Math.min(1, Math.max(0, -box.top / travel)) : 0;
  /* Потом пишем и больше ничего не читаем. */
  for (var i = 0; i < items.length; i++) {
    items[i].style.transform = 'translate3d(' + -progress * 1200 + 'px,0,0)';
  }
}

window.addEventListener('scroll', function () {
  if (queued) return;
  queued = true;
  requestAnimationFrame(paint);
}, { passive: true });

Четыре вещи, каждая из таблицы выше: passive: true, флаг вместо работы в обработчике, одно чтение на кадр до всех записей, сдвиг трансформом. Прогресс считается от секции, а не от window.scrollY — тогда лента не зависит от того, что стоит на странице выше.

Двадцать элементов сдвигаются одним циклом. Если их больше сотни, дешевле сдвигать один контейнер, а не каждый элемент: пересчёт стилей платится за каждый изменённый узел, и это видно в разнице 620 против 31.

Второй сюжет: после ленты перестала работать липкость

Горизонтальный вылет обрезают контейнером с overflow. И вот что при этом происходит с position: sticky внутри него — замер, пять контейнеров, липкий элемент с top: 20px, прокрутка до места, где он обязан прилипнуть:

Контейнерoverflow-xoverflow-ytop липкогоПрилип
без ограниченийvisiblevisible20да
overflow: hiddenhiddenhidden−380нет
overflow-x: hiddenhiddenauto−380нет
как .t396__artboardhiddenhidden−380нет
overflow-x: clipclipvisible20да

overflow-x: hidden — обычный способ убрать горизонтальную полосу — отменяет липкость. Причина в том, что hidden создаёт прокручиваемый контейнер, и липкий элемент начинает считать свою границу по нему, а не по окну. clip контейнера не создаёт: то же обрезание, липкость на месте.

Четвёртая строка — это артборд Zero Block. Его собственное правило:

.t396__artboard { position: relative; width: 100%; height: 100vh; overflow: hidden; }

Внутри артборда position: sticky не работает никогда, и это не поправить своим кодом изнутри.

Тильда делает это сама, со всей страницей

Самое неожиданное — не артборд, а вот эта функция из рантайма Zero Block:

function t396__setOverflowToAllRecords() {
  var t = document.getElementById('allrecords');
  if (t) {
    var e = ['.t951__sidebar_sticky', '.t1206__content_sticky', '.t754__col_fixed',
             '.t776__col_fixed', '.t786__col_fixed',
             '.t-store__prod-popup__col_fixed', '.t-catalog__sidebar_sticky'];
    t396__generateOverflowStyle();
    var i = Boolean(document.querySelector(e.join(', ')));
    t.classList.remove('t-records__overflow-clip', 't-records__overflow-hidden');
    t.classList.add(i ? 't-records__overflow-clip' : 't-records__overflow-hidden');
  }
}

Стили она вставляет сама, в <style id="t396__overflow-styles">:

.t-records__overflow-clip { overflow: clip; }
.t-records__overflow-hidden { overflow: hidden; }

Читается так: Тильда вешает overflow на #allrecords — контейнер всей страницы. clip она выбирает, только если на странице нашёлся один из семи её собственных липких блоков: липкая колонка в t951, липкое содержимое в t1206, фиксированные колонки t754, t776, t786, липкая колонка в карточке товара и липкая боковина каталога. Своих липких элементов в этом списке, разумеется, нет — на них достаётся overflow: hidden на всю страницу, и они перестают прилипать.

Вызывается это не всегда, а при одном условии — когда у артборда включён вылет:

var n = r.getAttribute('data-artboard-ovrflw');
if (['visible', 'visibleX'].includes(n) && (t396__setOverflowToAllRecords(), …

В четырёх снятых страницах Тильды атрибута data-artboard-ovrflw нет ни разу: по умолчанию его не ставят. Он появляется, когда в настройках артборда разрешают содержимому выходить за границы — а разрешают его как раз для горизонтальных лент и вылетающих элементов. То есть последовательность обычная: включили вылет у артборда, чтобы лента не обрезалась, — и на всей странице появился overflow: hidden, от которого перестала прилипать шапка, сделанная своим кодом.

Что делать. Если на странице есть свой липкий элемент и артборд с вылетом одновременно — вернуть странице clip вместо hidden, тем же способом, каким это делает Тильда для своих блоков:

#allrecords.t-records__overflow-hidden { overflow: clip; }

Одно правило, обрезание сохраняется, липкость возвращается — по замеру выше clip её не отменяет. Проверить, что именно стоит на странице, можно одной строкой из консоли; это в проверках ниже.

Что проверить у себя

// 1. Кто вообще ограничивает прокрутку над липким элементом
(function (el) {
  var out = [];
  for (var p = el.parentElement; p; p = p.parentElement) {
    var s = getComputedStyle(p);
    if (s.overflow !== 'visible' || s.overflowX !== 'visible' || s.overflowY !== 'visible')
      out.push((p.id || p.className) + ' → ' + s.overflowX + ' / ' + s.overflowY);
  }
  return out;
})(document.querySelector('.js-my-sticky'));

// 2. Что Тильда поставила на всю страницу
document.getElementById('allrecords').className.match(/t-records__overflow-\w+/);

// 3. Включён ли вылет у артборда — из-за чего это и произошло
[...document.querySelectorAll('.t396__artboard')]
  .map(a => a.id + ' → ' + a.getAttribute('data-artboard-ovrflw'));

// 4. Пассивный ли обработчик — видно по тому, сколько их вообще
getEventListeners(window).scroll?.map(l => l.passive);   // только в Chrome DevTools

// 5. Появилась ли горизонтальная полоса у страницы
document.documentElement.scrollWidth + ' против ' + window.innerWidth;

Если во второй проверке стоит t-records__overflow-hidden, а на странице есть свой липкий элемент — это оно.

Граница MOD-178

План M7 отдельно ставил вопрос, не является ли липкий горизонтальный проход режимом MOD-055 или MOD-127. После технической раскладки это разные продукты:

  • MOD-055 управляет переходом между целыми вертикальными секциями;
  • MOD-127 относится к глубине движения обложки;
  • MOD-178 удерживает одну секцию и переводит её вертикальный прогресс в горизонтальный путь конкретной ленты.

У них можно переиспользовать планировщик прокрутки, но разметка, параметры и критерий результата разные. Поэтому M7 получил отдельный id, а не третий режим в карточке, где дизайнеру пришлось бы разбираться в несовместимых сценариях.

Спецификация MOD-178 закрепляет измеренные требования этой статьи: transform вместо left, чтения до записей, один кадр, overflow: clip, проверка родной липкости и обычная прокручиваемая лента при prefers-reduced-motion.

Свободные моды библиотеки — в каталоге; проявление по прокрутке собрано в MOD-041. Если нужна лента с инерцией, синхронная с прокруткой на телефоне, или липкая шапка, которая уживается с артбордом на вылете — это работа под задачу: мод под задачу.

Вопросы по этой статье 3

Только transform. Замер на одном и том же эффекте: сдвиг через left дал 31 пересчёт раскладки против 1 у transform, а в паре с чтением сразу после записи — 620 против 20 и 19 миллисекунд только на раскладку против нуля. transform не меняет раскладку в принципе.