Страница открылась, текст встал по местам, палец потянулся к кнопке "Оформить заказ" – и в этот момент заголовок стал выше, кнопка уехала вниз, а клик ушёл в пустоту. Сцена знакома каждому второму пользователю и каждому владельцу сайта, недоумевающему, куда деваются конверсии. За прыжки отвечает метрика Cumulative Layout Shift, сокращённо CLS, одна из трёх метрик Core Web Vitals наряду с LCP и INP, по которым поиск оценивает удобство страниц. Веб-шрифты – одна из самых частых причин: пока файл начертания едет по сети, браузер либо прячет текст, либо рисует подмену из системной коллекции, а когда файл загружен, метрики не совпадают, и вёрстка пересобирается на глазах. Решение умещается в пару строк: правило font-display: swap в дескрипторе @font-face плюс предзагрузка критического шрифта тегом link rel preload. Ниже механика сдвига, пороги, тайминги значений font-display и примеры кода.
Почему невидимый текст и подмена шрифта двигают весь макет
Когда браузер видит в CSS блок @font-face, он не бросается качать файл немедленно. Шрифты загружаются по требованию: сначала строятся DOM и CSSOM, страница раскладывается, и только потом браузер смотрит, какие семейства нужны видимому тексту, и отправляет запрос. В очереди сети файл встаёт позади стилей, скриптов и первых картинок, и на типичном мобильном канале до загрузки проходят 400-900 миллисекунд.
Дальше у браузера два сценария, оба описаны в спецификации CSS Fonts Module. Первый называется FOIT, Flash of Invisible Text: текст рисуется невидимым, место под ним держится по метрикам резервного шрифта, а пользователь смотрит на пустые строки. Второй сценарий называется FOUT, Flash of Unstyled Text: сразу показывается системное начертание, а после загрузки происходит обмен. В Chrome период невидимости при поведении по умолчанию тянется до трёх секунд, потом всё равно включается подмена.
Оба сценария кончаются одинаково. Резервный шрифт и фирменный почти никогда не совпадают по метрикам: разные высота строчных знаков, ascent, descent и ширина глифов. Разная ширина меняет точки переноса строк, разная вертикаль меняет высоту каждой строки. Заголовок, который занимал в резерве 24 пикселя, в фирменном начертании занимает 27, три строки превращаются в две, и блок растёт или худеет на десятки пикселей. Всё, что стоит ниже, уезжает, и это фиксируется как непреднамеренный сдвиг макета. Отдельно страдает интерфейс: кнопка, ушедшая из-под пальца на 60 пикселей, превращает уверенный клик в промах, хотя виноват файл на 70 килобайт.
Страдает прежде всего первый визит: повторные заходы берут шрифт из кеша, обмен успевает пройти до первой отрисовки. Но именно первый визит решает, будет ли второй.
Как считается метрика CLS и что означают пороги 0.1 и 0.25
CLS не считает пиксели, он считает долю экрана. Формула короткая: доля воздействия, умноженная на долю смещения. Доля воздействия (impact fraction) – площадь объединения видимых областей сдвинувшихся элементов до и после прыжка, поделённая на площадь всего вьюпорта. Доля смещения (distance fraction) – наибольшее расстояние, которое проехал самый подвижный из нестабильных элементов, поделённое на большую сторону вьюпорта, ширину или высоту. Произведение даёт безразмерное число: чем шире задетая зона и дальше уехал контент, тем оно выше.
Арифметика на примере. Экран смартфона 412 на 915 пикселей. Подмена шрифта в статье удлинила текст, видимые абзацы уехали на 180 пикселей вниз, а объединённая область до и после сдвига заняла 57 процентов экрана. Доля воздействия равна 0.57, доля смещения равна 180 разделить на 915, то есть примерно 0.2. CLS этого кадра: 0.57 умножить на 0.2, получаем 0.114. Порог уже пробит.
Пороги закреплены официально: значение до 0.1 включительно считается хорошим, от 0.1 до 0.25 – требующим улучшения, выше 0.25 – плохим. Сдвиги складываются внутри окна сессии длиной до пяти секунд с разрывом в одну секунду, итогом страницы считается максимальное окно, поэтому цепочка мелких прыжков копится в крупную проблему: шрифт заголовков дал 0.06, текстовый ещё 0.05, и сумма 0.11 уже требует улучшений, хотя по отдельности всё выглядело безобидно. Есть защита от ложных тревог: сдвиги в первые 500 миллисекунд после пользовательского ввода в метрику не попадают. Шрифтовой обмен под исключение не подпадает: он происходит сам, без ведома пользователя.
Пять значений font-display и их периоды блокировки и обмена
Дескриптор font-display живёт внутри @font-face и говорит браузеру, как себя вести, пока файл в дороге. Значений пять, и у каждого свои тайминги:
- auto – стратегию выбирает браузер, в Chrome поведение близко к block, с невидимым текстом на весь период блокировки;
- block – период блокировки около трёх секунд, текст невидим, затем показывается резерв, а обмен возможен в любой момент после загрузки;
- swap – блокировка около 100 миллисекунд, обмен происходит сразу по готовности файла, сколько бы тот ни ехал;
- fallback – блокировка около 100 миллисекунд, затем резерв и окно обмена около трёх секунд, по истечении которых файл на этой странице уже не применяется;
- optional – блокировка около 100 миллисекунд и нулевое окно обмена: если файл не успел за это время, браузер в текущем визите уже не подменяет резервный шрифт на фирменный, чтобы гарантированно не сдвигать макет, а файл лишь докачивается в кеш для следующих заходов.
Для критического текста выбирают swap: читаемый резерв появляется почти мгновенно, а если файл приедет за 200-300 миллисекунд благодаря предзагрузке, большинство читателей не заметит обмена. block лишь прячет проблему: пустые строки не двигают макет, зато задерживают чтение и превращают первый экран в заглушку. optional честно убирает прыжок в текущем визите, но часть аудитории увидит фирменное начертание только со второго захода, поэтому его оставляют декоративным акцентам, а не заголовкам. auto меняют хотя бы ради предсказуемости: браузеры трактуют его по-разному.
Правило занимает одну строку:
@font-face {
font-family: "Manrope";
src: url("/fonts/manrope-var.woff2") format("woff2");
font-weight: 200 800;
font-display: swap;
}
Если стили собирает внешний сервис, стоит открыть готовый CSS и убедиться, что swap доехал до продакшена.
Предзагрузка критических шрифтов через link rel preload
Swap правит поведение браузера после загрузки файла, но не ускоряет саму загрузку. А шрифт, в отличие от картинок, браузер замечает поздно: файл упоминается внутри CSS, а запрос рождается только после скачивания стилей, их разбора и первой раскладки. На медленной сети цепочка ожиданий съедает секунду и больше. Тег link с rel preload вырывает файл из этой очереди и отправляет запрос параллельно с качающимся CSS:
<link rel="preload" href="/fonts/manrope-var.woff2" as="font" type="font/woff2" crossorigin>
У тега четыре обязательные детали. Атрибут as="font" задаёт тип ресурса и высокий приоритет запроса. Атрибут type="font/woff2" страхует старые браузеры, чтобы не качать файл зря. Атрибут crossorigin обязателен даже для файла со своего домена: шрифты всегда запрашиваются в анонимном CORS-режиме, и без атрибута браузер скачает один и тот же файл дважды. Адрес в href обязан совпадать с адресом в @font-face до последнего символа, включая параметры в query-строке, иначе предзагрузка и шрифтовой запрос разъедутся в два скачивания. Ставят тег в начало head, до стилей, а для шрифтов с отдельного домена рядом добавляют link rel preconnect.
Предзагружать стоит один, максимум два файла: начертание для заголовков и, если первый экран построен на тексте, начертание основного массива. Каждая лишняя предзагрузка конкурирует за канал с картинкой первого экрана и стилями, а браузер приглушает подсказки, которые не пригодились в первые секунды. Проверка занимает минуту: в DevTools на панели Network ставится фильтр Font, и по колонке Waterfall видно, ушёл ли запрос шрифта рядом со стилями или ждал их. Формат вне обсуждения: начертание в TTF нередко весит 200-400 килобайт, в WOFF2 тот же набор глифов сжимается до 30-80 килобайт, и грузить тяжелее резона нет.
Подгонка резервного шрифта через size-adjust и font-size-adjust
Swap убирает пустоту, preload сокращает ожидание, но прыжок исчезает совсем, только когда резервное начертание заранее подогнано под метрики фирменного. Разница рождается из вертикальных метрик ascent, descent и line-gap плюс из разной ширины глифов, меняющей переносы. CSS умеет заставить системный шрифт рисоваться с габаритами фирменного ещё до его загрузки: дескриптор size-adjust растягивает контуры и метрики шрифта-источника в процентах, а ascent-override, descent-override и line-gap-override заменяют вертикальные метрики явными значениями.
@font-face {
font-family: "Manrope-fallback";
src: local("Arial");
size-adjust: 111%;
ascent-override: 96%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: "Manrope", "Manrope-fallback", sans-serif;
}
Браузер рисует текст подогнанным резервом с первой отрисовки, и когда приезжает настоящий Manrope, геометрия строк не меняется, меняется только рисунок букв. Даже грубая подгонка без точных ширин глифов сжимает прыжок с 15-25 пикселей до 1-3, а на глаз обмен перестаёт ощущаться вовсе. Выбор резерва тоже измерим: в роли подмены ищут метрически близкие системные семейства, для гротесков это Arial, Segoe UI, Roboto и Helvetica Neue, для антиквы Georgia и Times New Roman. Существует и свойство font-size-adjust: оно выравнивает шрифты по высоте строчных знаков, чтобы смена начертания не меняла воспринимаемый размер текста. Точные проценты берут из таблицы метрик начертания, генераторы вроде fontaine или next/font в Next.js собирают такой дескриптор автоматически.
Вариативные шрифты и подмножества глифов вместо лишних килобайт
Второй фронт – вес. Классический набор из четырёх статических начертаний в WOFF2, от light до bold, это четыре файла по 30-60 килобайт и четыре запроса, итого 120-240 килобайт до полной типографики. Вариативный шрифт несёт все начертания в одном файле: ось weight покрывает диапазон от 200 до 800, нужное значение выбирается интерполяцией на лету, а сам файл весит порядка 70-110 килобайт. В дескрипторе диапазон задаётся записью font-weight: 200 800, без дополнительных загрузок; часть файлов включает и курсивную ось, тогда italic не требует отдельного файла.
Дальше работают подмножества. Дескриптор unicode-range перечисляет кодовые диапазоны, которые покрывает конкретный файл: отдельно латиница, отдельно кириллица, отдельно символы валют. Браузер скачивает только те файлы, чьи глифы реально встретились в тексте, поэтому страница на чистой кириллице не тянет латинский набор и наоборот. Сабсетинг обрезает лишнее напрямую: каждый вырезанный килобайт ускоряет загрузку тех файлов, ради которых всё затевалось.
Контрольный вопрос к каждому файлу: реально ли он участвует в отрисовке. Начертания из дизайн-макета, которых нет в собранной вёрстке, убирают из списка: предзагрузка не спасёт файл, который не нужен, а лишний вес оттянет нужные.
Замеры до и после в Lighthouse и Search Console
Типовая картина в цифрах. Магазин на мобильных показывает CLS 0.19 при пороге 0.1: заголовки первого экрана рисовались системным шрифтом, потом обменивались на фирменный и двигали призыв к действию на 40-60 пикселей. Правки умещаются в один коммит: swap во все дескрипторы, preload одного вариативного файла на 88 килобайт, подгонка резерва через size-adjust. После деплоя мобильный CLS по реальным пользователям опускается до 0.02-0.03, дорожка сдвигов в DevTools пустеет, а кнопки перестают уходить из-под пальца.
Замеряют так. Быстрая проверка одной страницы – Lighthouse в Chrome DevTools: аудит отдаёт CLS лабораторно и перечисляет конкретные сдвинувшиеся элементы. В панели Performance есть дорожка Layout Shifts, она подсвечивает прыгнувшие блоки прямо на странице, и по подсветке сразу видно, что виноват обмен шрифта в первом экране, а не картинка без размеров. Для реальных пользователей работает отчёт Core Web Vitals в Search Console: он агрегирует визиты и выставляет статус группам страниц и устройствам на уровне 75-го перцентиля, то есть по опыту худшей четверти аудитории. Данные в отчёте плывут с задержкой, окно 28 дней, поэтому реакцию на правки ждут днями. Для собственных дашбордов существует библиотека web-vitals с функцией onCLS, значение которой уходит в аналитику:
<script type="module">
import { onCLS } from "web-vitals";
onCLS((metric) => console.log(metric.value));
</script>
Порядок работ не требует рефакторинга: swap во все дескрипторы, предзагрузка файла начертания заголовков, подгонка метрик резерва, контрольный замер. Макет перестанет прыгать не тогда, когда сеть станет быстрее, а когда страница перестанет заставлять браузер гадать, каким шрифтом рисовать текст и сколько места держать под него.