CSS Scroll Anchoring Module Level 1: Конец «дергающемуся» контенту
Если вы когда-либо читали длинную статью в интернете и внезапно теряли место, где остановились, потому что страница «подпрыгнула» из-за загрузившейся рекламы, изображений или видео, то вы сталкивались с проблемой, которую призван решать CSS Scroll Anchoring.
Спецификация CSS Scroll Anchoring Module Level 1 — это документ W3C, описывающий механизм, который браузеры используют для предотвращения неожиданных сдвигов содержимого страницы.
Зачем нужна эта спецификация?
Представьте: вы прокрутили страницу до интересного абзаца и начали читать. В этот момент где-то выше, за пределами видимой области (вне вьюпорта), загружается баннер или изображение без заданных размеров. Браузер изменяет высоту блока, и весь контент под ним «уезжает» вниз. Вы теряете фокус чтения. Это называется Content Shift (сдвиг контента) и создает крайне негативный пользовательский опыт (CLS — Cumulative Layout Shift — является одним из ключевых метрик Core Web Vitals).

Исторически браузеры сохраняли абсолютную позицию прокрутки. Если контент добавлялся сверху, скролл оставался на месте, но страница под ним уезжала.
Scroll Anchoring меняет это поведение. Вместо фиксации позиции скролла, браузер фиксирует позицию DOM-элемента, который видит пользователь. Если контент наверху меняется, браузер автоматически корректирует позицию прокрутки, чтобы «якорный» элемент оставался на том же месте на экране.
Как это работает: Якорь и Регулировка
На практике у браузера здесь две задачи: сначала выбрать «точку опоры» (якорь), потом компенсировать сдвиг.
- Шаг 1: выбор якорного узла (Anchor Node Selection) Браузер ищет элемент внутри прокручиваемого контейнера, который лучше всего отражает то место, где сейчас находится взгляд пользователя.
Он проверяет кандидатов по правилам:
- элемент должен быть потомком текущего скролл-контейнера;
- элемент должен быть хотя бы частично видимым;
- элемент не должен быть в исключенном поддереве (например,
display: none,position: fixed,overflow-anchor: none, про который будет ниже, а также некоторыеposition: absolute); - не подходят неатомарные inline-элементы (например, обычные строчные фрагменты текста в
<span>,<a>,<strong>, когда они участвуют в inline-потоке и не являются отдельным «атомарным» боксом вродеinline-blockилиinline-table).
Отдельно есть приоритетные кандидаты:
- элемент, связанный с текущим фокусом, например вы работает с формой и один из элементов формы сфокусирован;
- элемент с активным совпадением поиска по странице (find-in-page). Это когда вы делаете (
Ctrl + F) поиск по странице.
Я не уверен, но вроде бы это касается ссылок на выделенный текст #:~:text=ссылка%20на%20текст
Если приоритетный кандидат подходит, браузер берет его сразу. Если нет, он обходит DOM: для частично видимых узлов сначала пытается найти более глубокий подходящий дочерний узел, и только если не нашел, выбирает текущий. Поэтому в норме якорь получается «глубоким» в DOM.

-
Шаг 2: отслеживание смещения якоря Когда макет меняется, браузер сравнивает старую и новую позицию блочного начала якоря: было
y0, сталоy1. -
Шаг 3: корректировка прокрутки (Scroll Adjustment) Браузер ставит в очередь компенсацию на величину
y1 - y0в направлении блочного потока. Проще говоря: если якорь «уехал» вниз на 40px, скролл тоже сдвинется на 40px, чтобы визуально удержать контент на месте.
Когда происходит корректировка?
Спецификация вводит термин suppression window(окно подавления).
Коррекция применяется не мгновенно, а в рамках короткого окна времени внутри текущей итерации event loop. За это окно может произойти несколько смещений якоря, и в конце браузер применит накопленные корректировки.
Это нужно, чтобы избежать дерганий и конфликтов с JavaScript, который в тот же момент меняет layout или читает геометрию (getBoundingClientRect() и т.д.).
Когда корректировка подавляется (suppression triggers)
Спецификация перечисляет случаи, когда компенсацию нужно отменить для совместимости:
- изменение
top/left/right/bottom,margin,padding,width/height(и связанных longhand),position,transformна пути от якоря к скроллеру; - элемент внутри скроллера стал или перестал быть абсолютно позиционированным;
- текущий scroll offset у контейнера равен
0.
Итоговая идея простая: браузер старается держать стабильным именно тот контент, который пользователь сейчас читает, но в потенциально конфликтных ситуациях аккуратно «отступает», чтобы не ломать существующее поведение страницы.
Управление поведением: Свойство overflow-anchor
Спецификация предоставляет веб-разработчикам API для управления этим поведением через CSS-свойство overflow-anchor. Хотя механизм включен по умолчанию (чтобы защитить пользователя даже на старых сайтах), у разработчика есть возможность его отключить.
Синтаксис и значения
Свойство применяется ко всем элементам и не наследуется.
-
overflow-anchor: auto(значение по умолчанию): Элемент может быть выбран в качестве якоря (или влиять на выбор якоря) для любой прокрутки, созданной им самим или его предком. -
overflow-anchor: none: Элемент и его потомки (кроме вложенных прокручиваемых контейнеров) исключаются из алгоритма выбора якоря для прокруток, созданных этим элементом или его предками.Важное примечание: Если вы установили
overflow-anchor: noneна родителе, вы не сможете «вернуть» якорение обратно для его дочерних элементов. Однако, если дочерний элемент сам является прокручиваемым контейнером (например, у него стоитoverflow: scroll), для его собственной прокрутки якорение будет снова активно (если только вы не пропишетеnoneи для него).
Честно сказать я не знаю как точно это назвать, но я назвал “якорение”. Якорение - в рамках этой статьи означает технологию, которая “привязывает” контент, который вы сейчас читаете, к определённой точке на экране.
Практические примеры
Давайте рассмотрим, как это работает и как применять свойство на практике.
Пример 1: Проблема с загрузкой контента
Представим типичную проблему: есть лента новостей или блок с комментариями, над которым динамически подгружается баннер.
HTML:
<div class="container">
<div class="ad-banner" id="ad">
<!-- Здесь будет загружена реклама -->
</div>
<div class="content">
<p>1. Очень важный текст, который читает пользователь...</p>
<p>2. Продолжение текста...</p>
<p id="anchor-point">3. Здесь читатель остановился.</p>
<p>4. Еще текст...</p>
</div>
<button id="load-ad">Загрузить рекламу</button>
</div>
CSS:
.container {
overflow-y: auto; /* Делаем контейнер прокручиваемым */
}
.ad-banner {
/* Изначально баннер скрыт или имеет нулевую высоту */
height: 0;
overflow: hidden;
}
.ad-banner.loaded {
height: 100px; /* Баннер появился и занял место */
}
JavaScript (симуляция загрузки): При клике на кнопку загрузки появится баннер в начале блока.
const button = document.getElementById('load-ad');
const ad = document.getElementById('ad');
button.addEventListener('click', () => {
ad.classList.add('loaded');
ad.textContent = '🤑 ВАЖНАЯ РЕКЛАМА 🤑';
});
Демонстрация поведения: Открыть демо-страницу, чтобы прочувствовать
Если пользователь прокрутит контейнер до третьего абзаца и нажмет кнопку «Загрузить рекламу», баннер появится сверху. При стандартном поведении (без якорения) текст «уедет» вниз. Благодаря Scroll Anchoring (активному по умолчанию) браузер удержит третий абзац на том же месте относительно окна контейнера, автоматически прокрутив контейнер вниз на те же 100px.
Пример 2: Отключение якорения (overflow-anchor: none)
Иногда автоматическая корректировка мешает. Например, если вы делаете бесконечную ленту (infinite scroll) с подгрузкой постов сверху (как в некоторых чатах). В этом случае якорение будет постоянно пытаться удержать текущий пост на месте, не давая ленте плавно подгружать старые сообщения вверх.
В этом случае якорение нужно отключить для прокручиваемого контейнера.
HTML:
<div class="chat" id="chat">
<div class="message">Старое сообщение 5</div>
<div class="message">Старое сообщение 6</div>
<div class="message" id="current-view">Сообщение, которое читают</div>
<div class="message">Новое сообщение 1</div>
<!-- Сюда будут добавляться новые сообщения -->
</div>
<button id="load-more">Загрузить старые сообщения</button>
CSS (с отключенным якорением):
.chat {
width: 300px;
height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column-reverse; /* Сообщения добавляются снизу вверх */
/* Отключаем Scroll Anchoring для этого контейнера */
overflow-anchor: none;
}
/* Альтернатива: отключаем якорение для конкретного элемента */
/* .message {
overflow-anchor: none;
} */
.message {
border: 1px solid #ccc;
padding: 5px;
margin: 2px 0;
}
JavaScript:
const chat = document.getElementById('chat');
const button = document.getElementById('load-more');
button.addEventListener('click', () => {
// Симулируем загрузку "старых" сообщений
for (let i = 0; i < 3; i++) {
const oldMsg = document.createElement('div');
oldMsg.className = 'message';
oldMsg.textContent = `Загруженное историческое сообщение #${Date.now()}`;
// Вставляем в начало контейнера (старые сообщения сверху)
chat.insertBefore(oldMsg, chat.firstChild);
}
});
Демонстрация поведения: Открыть демо-страницу с отключением якорения
В данном случае, если бы не overflow-anchor: none, при загрузке старых сообщений и вставке их сверху, браузер пытался бы удержать на месте элемент #current-view, создавая неприятный скачок. Отключение якорения возвращает контроль разработчику.
Пример 3: Частичное исключение
Можно исключить из поиска якоря только конкретный проблемный элемент, оставив механизм работать для остальных.
/* Рекламный блок не должен влиять на выбор якоря */
.ad-banner {
overflow-anchor: none;
}
/* Основной контент участвует в якорении */
.main-content {
overflow-anchor: auto; /* Или просто не указываем ничего */
}
В этом случае, если якорь (например, какой-то абзац текста) находится внутри .main-content, браузер будет его учитывать. При этом изменения внутри самого .ad-banner (например, смена контента, которая не меняет его высоту) не будут триггерить перерасчет якоря, хотя изменения его размеров все равно повлияют на сдвиг (который и будет скомпенсирован).
Поддержка браузерами
overflow-anchor на данный момент не имеет нормального статуса в Baseline, так как не поддерживается Safari, хотя их об этом просят с 2017 года.
В Chrome свойство работает с 56 версии 2017 года. В Firefox с 66 версии 2019 года.