CLS: почему страница «прыгает»

Вы читаете абзац — и текст уезжает вниз. Целитесь в «Отмена» — попадаете в «Удалить». Разберём, как это измеряется и как это лечится.

CLS (Cumulative Layout Shift) — метрика визуальной стабильности: сумма оценок всех неожиданных сдвигов вёрстки в худшем пятисекундном окне за время жизни страницы. «Хорошо» — 0,1 и меньше. Выше 0,25 — «плохо».

Зачем это знать

LCP и INP про скорость. CLS — про доверие. Прыгающая вёрстка — единственная проблема производительности, которую пользователь замечает сознательно и на которую злится: он промахивается по кнопкам, теряет строку в тексте, случайно оформляет подписку. Хуже того, CLS почти не виден в разработке: на локалхосте всё грузится мгновенно, и прыжка нет. Он проявляется у пользователя с 4G — то есть у большинства.

Как считается один сдвиг

Формула из двух множителей:

оценка сдвига = impact fraction × distance fraction

  • impact fraction — какую долю вьюпорта задел нестабильный элемент. Берётся объединение его прямоугольника «до» и «после» сдвига.
  • distance fraction — на сколько он уехал, делённое на бо́льшую сторону вьюпорта.

Посчитаем на живом примере: вьюпорт 1280×800, блок текста высотой 300 px стоял на отметке 100 px от верха, а потом догрузилась картинка и сдвинула его на 300 px вниз.

const viewport = { width: 1280, height: 800 };

// Прямоугольник блока текста до и после сдвига
const before = { top: 100, height: 300 };
const after  = { top: 400, height: 300 };

// 1. impact fraction: объединение «до» и «после» относительно площади вьюпорта
const unionTop = Math.min(before.top, after.top);
const unionBottom = Math.max(before.top + before.height, after.top + after.height);
const impactArea = (unionBottom - unionTop) * viewport.width;
const impactFraction = impactArea / (viewport.height * viewport.width);

// 2. distance fraction: путь сдвига / большая сторона вьюпорта
const maxSide = Math.max(viewport.width, viewport.height);
const distanceFraction = Math.abs(after.top - before.top) / maxSide;

const score = impactFraction * distanceFraction;

console.log('impact fraction:  ', impactFraction.toFixed(3));
console.log('distance fraction:', distanceFraction.toFixed(3));
console.log('оценка сдвига:    ', score.toFixed(3));
console.log('Порог «хорошо» для CLS — 0.1. Один такой сдвиг уже проваливает метрику.');

Результат:

impact fraction:   0.750
distance fraction: 0.234
оценка сдвига:     0.176
Порог «хорошо» для CLS — 0.1. Один такой сдвиг уже проваливает метрику.

Обратите внимание, как легко провалить метрику. Одна картинка без размеров в начале статьи — и 0,176 при бюджете 0,1. И это всего один сдвиг из тех нескольких, что обычно случаются на реальной странице.

Важная деталь: сдвиги в течение 500 мс после действия пользователя (у записи взводится флаг hadRecentInput) в CLS не входят. Раскрыть аккордеон по клику — нормально. Раскрыть его сам по себе через две секунды — нет.

Четыре источника прыжков

1. Изображения без размеров

Самая частая причина. Браузер не знает высоту картинки, пока не скачает её, поэтому сначала верстает текст «впритык», а потом раздвигает.

<!-- ПЛОХО: место не зарезервировано, при загрузке всё уедет вниз -->
<img src="/img/hero.jpg" alt="Витрина магазина">

<!-- ХОРОШО: атрибуты width/height задают соотношение сторон, -->
<!-- браузер резервирует коробку ещё до загрузки файла -->
<img src="/img/hero.jpg" alt="Витрина магазина"
     width="1200" height="675" fetchpriority="high">
/* Атрибуты дают соотношение сторон, а CSS оставляет картинку адаптивной.
   Без height: auto картинка растянется до жёстких 675px. */
img,
video {
  max-width: 100%;
  height: auto;
}

Ключевой момент, который многие упускают: атрибуты width и height не «фиксируют размер» — из них браузер выводит aspect-ratio по умолчанию. С height: auto в CSS картинка остаётся резиновой, но коробка под неё выделяется заранее. Поэтому старое правило «размеры в HTML — это устаревшая практика, всё в CSS» здесь просто неверно.

2. Шрифты

Мы включили font-display: swap и убрали невидимый текст — но взамен получили перерисовку. Если у запасного шрифта другая ширина глифов и другая высота строки, абзац после подмены займёт другое число строк, и весь контент под ним уедет.

Лечится выравниванием метрик запасного шрифта под основной:

/* Основной шрифт */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

/* Запасной: системный Arial, «подогнанный» под метрики Inter.
   Числа берут из инструментов вроде fontaine или capsize —
   на глаз их не подобрать. */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter Fallback', Arial, sans-serif;
}

Идеально совпасть не получится, но сдвиг из заметного превращается в незаметный — а в CLS попадает только то, что реально сдвинулось.

3. Реклама, баннеры и виджеты

Рекламный блок приходит из чужой сети через 1–3 секунды и вставляет в поток 250 px. Всё, что ниже, прыгает. Решение — резервировать место заранее:

/* Резервируем ровно ту высоту, которую займёт блок.
   Если размер креатива плавает — резервируем максимально вероятный. */
.ad-slot {
  min-height: 250px;
  contain: layout; /* изменения внутри блока не пересчитывают вёрстку снаружи */
}

/* Плашка cookies и промо-баннеры: не в потоке документа, а поверх него.
   Тогда их появление физически не может ничего сдвинуть. */
.cookie-bar {
  position: fixed;
  inset: auto 0 0 0;
}

Правило простое: всё, что появляется позже и не по клику пользователя, не должно быть в потоке документа. Либо ему выделено место заранее, либо оно лежит поверх (position: fixed / absolute).

4. Вставка контента сверху

Самый болезненный случай: пользователь дочитал до середины статьи, и тут наверху страницы появляется плашка «Мы используем cookies» или подгружается блок «Читайте также». Контент под ней уезжает на 80 px, читатель теряет строку. Формально сдвиг небольшой, по ощущениям — катастрофа.

Разбор реального прыжка

Страница статьи. В отчёте PageSpeed Insights CLS = 0,31 — «плохо». Ищем причину по шагам:

  1. Открываем DevTools → Performance, ставим сеть Slow 4G, CPU throttling ×4 (без этого прыжка не будет — он воспроизводится только на медленной сети).
  2. Записываем загрузку. На таймлайне ищем дорожку Layout Shifts — красные полоски. Кликаем по каждой: DevTools подсвечивает виновный элемент и показывает Score.
  3. Находим три сдвига: обложка статьи (0,17), подмена шрифта в теле (0,06), плашка cookies сверху (0,08). Сумма — 0,31.

Логирование в коде даёт то же самое, но уже на живых пользователях:

let cls = 0;

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // сдвиги сразу после действия пользователя не считаются
    if (entry.hadRecentInput) continue;

    cls += entry.value;
    console.log(
      'сдвиг', entry.value.toFixed(4),
      '| виновник:', entry.sources[0]?.node,
      '| накоплено CLS:', cls.toFixed(4)
    );
  }
}).observe({ type: 'layout-shift', buffered: true });

Что чинили: обложке добавили width="1200" height="675" (0,17 → 0), шрифту прописали метрики запасного (0,06 → 0,01), плашку cookies перевели в position: fixed внизу экрана (0,08 → 0). Итог: 0,31 → 0,01. Три правки, ни одной новой строчки JS.

Как это работает

Браузер после каждого кадра сравнивает положение видимых элементов с предыдущим кадром. Если элемент, который уже был отрисован, сменил стартовую позицию — это layout shift. Два следствия, которые надо держать в голове:

  • Новый элемент сам по себе сдвигом не является. Сдвиг — это то, что он сделал с уже существующими соседями. Поэтому position: fixed и работает: элемент вне потока, соседи не двигаются.
  • Анимации через transform не считаются сдвигом, а через top/left/margin — считаются. Первое браузер выполняет на этапе композитинга, не трогая layout. Второе пересчитывает вёрстку каждый кадр — и это одновременно и просадка CLS, и лишняя работа для CPU. Анимируйте transform и opacity.

И про окно измерения: CLS — не сумма всех сдвигов за сессию, а худшее окно. Окно набирается так: сдвиги группируются, пока между ними меньше 1 секунды паузы и пока окно не растянулось на 5 секунд. Побеждает окно с максимальной суммой. Поэтому один аккуратный сдвиг в бесконечной ленте не накапливается до бесконечности — но пять сдвигов подряд при загрузке складываются в один плохой результат.

Частые ошибки

  • Убрали width/height у <img>, потому что «размеры задаёт CSS». Самый дорогой миф в этом списке. Атрибуты нужны — они дают браузеру соотношение сторон.
  • Забыли height: auto в CSS после добавления атрибутов — картинки на мобильном растянулись и поехали пропорции.
  • Проверяли CLS на десктопе с быстрым интернетом. Прыжка не видно — значит, его нет? Нет: включайте throttling или смотрите полевые данные CrUX.
  • Резервируют место «примерно». min-height: 100px под блок, который приходит на 250 px, — это всё тот же сдвиг, просто поменьше.
  • Анимируют появление через margin-top или height. Каждый кадр анимации — новый layout shift. Только transform.
  • Смотрят только на лабораторный CLS в Lighthouse. Он измеряет загрузку, а реальные пользователи ещё и скроллят, кликают, ждут ленивую подгрузку. Полевой CLS почти всегда хуже лабораторного.

Итоги

  • CLS = impact fraction × distance fraction, просуммированные в худшем 5-секундном окне. Цель — ≤ 0,1.
  • Каждому элементу, который появляется с задержкой, нужно место заранее: width/height у картинок, aspect-ratio у контейнеров, min-height у рекламных слотов.
  • Шрифты: font-display: swap плюс выравнивание метрик запасного шрифта (size-adjust, ascent-override).
  • Всё, что приходит позже и не по клику, — position: fixed/absolute, а не в поток документа.
  • Анимируйте transform и opacity. top, margin, height в анимациях — это сдвиг каждый кадр.
  • Ищите прыжки в DevTools → Performance с throttling, а на проде — через PerformanceObserver по типу layout-shift.
Проверьте себя
1. Что даёт указание width и height у <img>, если размеры всё равно переопределены в CSS (max-width: 100%; height: auto)?
AНичего, современные браузеры их игнорируют при наличии CSS
BБраузер выводит из них соотношение сторон и резервирует место под картинку ещё до её загрузки — сдвига не будет
CОни заставляют браузер загрузить изображение с более высоким приоритетом
DОни запрещают браузеру масштабировать картинку, из-за чего она становится нерезкой
2. Какой сдвиг вёрстки НЕ попадёт в метрику CLS?
AБаннер о cookies, вставленный сверху страницы через две секунды после загрузки
BСмена шрифта после загрузки веб-шрифта, из-за которой абзац стал на строку выше
CРаскрытие аккордеона, произошедшее в пределах 500 мс после клика пользователя по заголовку
DПрыжок контента вниз из-за загрузившейся рекламы в блоке без резервирования