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 — «плохо». Ищем причину по шагам:
- Открываем DevTools → Performance, ставим сеть Slow 4G, CPU throttling ×4 (без этого прыжка не будет — он воспроизводится только на медленной сети).
- Записываем загрузку. На таймлайне ищем дорожку Layout Shifts — красные полоски. Кликаем по каждой: DevTools подсвечивает виновный элемент и показывает Score.
- Находим три сдвига: обложка статьи (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.