С чего началось
Скорость загрузки — тот пункт, на котором нельзя экономить при разработке сайта, и мы пишем это клиентам. Поэтому начали с себя: прогнали Lighthouse по собственному сайту — не по клиентскому, по своему. Мобильная оценка производительности показала 76 при почти идеальных остальных метриках: время до первого байта полсекунды, блокировка главного потока 60 мс, сдвиг вёрстки 0,001. И LCP 7,0 секунды.
Сочетание странное. Обычно плохой LCP идёт в компании с плохим TBT: тяжёлый JavaScript душит и то и другое. Здесь главный поток был свободен, картинок в первом экране нет, шрифты грузились со swap. Разбивка LCP объясняла всё одной строкой: время до первого байта — 0,55 с, остальные 6,4 секунды — «element render delay». Элемент был доставлен и не отрисован.
Первая гипотеза — и почему она не сработала
В разметке первого экрана обнаружилось 95 элементов с opacity: 0, включая сам LCP-элемент — абзац под заголовком. Причина понятная: framer-motion записывает начальное состояние анимации прямо в атрибут style при серверном рендере. Пока React не загрузится и не запустит анимацию, текст физически невидим.
<p class="max-w-xl text-lg leading-relaxed text-ink-dim"
style="opacity:0;transform:translateY(20px)">
От лендинга до онлайн-банка — один партнёр на весь цикл…
</p>Объяснение выглядело исчерпывающим, и мы переписали анимацию первого экрана на CSS: те же кадры, та же кривая, та же длительность — но начинается она с первым кадром отрисовки, а не после гидратации. Собрали, замерили.
Ничего не изменилось. LCP как был 5,0 с на локальном билде, так и остался 5,0 с. Это и есть главный момент разбора: правдоподобное объяснение и правильное объяснение — разные вещи, и различает их только замер.
Гипотеза, которая объясняет симптом, но не исчезает вместе с ним после починки, — не причина, а совпадение.
Настоящая причина
Дальше мы посмотрели не на стили LCP-элемента, а на то, что находится над ним. В разметке нашлась интро-заставка — та самая, с бегущими цифрами, которая на десктопе создаёт настроение при входе:
<div class="fixed inset-0 z-[1000] flex flex-col justify-between bg-base p-6">
… <!-- 000 · booting · Loading experience -->
</div>Непрозрачный слой на весь экран, поверх всего. На телефоне интро показывать не нужно, и код это учитывал — условие проверял хук useCanRender3D(): десктоп, точный указатель, достаточно ядер и памяти. Проблема в том, когда это условие становится известно. Хук — это React, React — это гидратация. До неё сервер не знает, кто на другом конце, и рендерит заставку всем.
Получалась замкнутая конструкция: чтобы убрать заглушку, нужен JS; пока его нет, пользователь смотрит на пустой тёмный экран, хотя весь контент уже у него. Заодно объяснилось, почему первая гипотеза не помогла: прозрачность абзаца не имела значения — его всё равно закрывали сверху.
Починка
Решение — перенести условие показа из JavaScript в CSS. Медиазапрос вычисляется при первом же расчёте стилей, до всякого React, и на телефоне заставка просто не существует как нарисованный слой.
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
.intro-gate {
display: none !important;
}
}Вторая часть — та самая переписанная на CSS анимация первого экрана. Сама по себе она ничего не дала, но после снятия заслонки стала обязательной: убрав верхний слой, мы бы упёрлись ровно в opacity: 0 следующим шагом. Две ошибки маскировали друг друга.
- 01
Заставка спрятана медиазапросом
Слой перестал рисоваться на устройствах, где интро не показывают, — независимо от того, загрузился ли JavaScript.
- 02
Анимация первого экрана переехала на CSS
Кадры, длительности и кривая прежние. Разница только в моменте старта: первый кадр отрисовки вместо конца гидратации.
- 03
Ничего не удалено
На десктопе интро идёт как шло. Это важно: фирменная анимация — часть продукта, а не то, чем расплачиваются за метрику.
Результат
Замеры сделаны на одной машине, на одном и том же коде, с реальным троттлингом (Lighthouse, мобильный профиль, --throttling-method=devtools), локальный прод-билд — чтобы исключить сеть и разброс.
Как проверить это у себя
- Откройте исходный код страницы (именно
view-source, а не инспектор — инспектор показывает состояние после JS) и найдите текст своего заголовка. Если его там нет, разговор про LCP преждевременен: сначала серверный рендер. - Найдите в этом же HTML
opacity:0,visibility:hiddenиtransform: translateрядом с контентом первого экрана. Всё это — невидимый текст. - Найдите элементы с
position: fixedи высокимz-index, которые рендерятся всегда, а скрываются по условию из JS. Заставки, прелоадеры, баннеры согласия. - Прогоните Lighthouse с
--throttling-method=devtoolsи посмотрите разбивку LCP. Большойelement render delayпри маленьком TTFB — это ваш случай. - Проверьте, не живёт ли условие показа чего-то крупного в React-хуке. Всё, что можно решить медиазапросом, должно решаться медиазапросом.
Общее правило, которое мы из этого вынесли: всё, что закрывает контент, должно уметь исчезать без JavaScript. Если снять слой может только скрипт, то время его загрузки становится временем появления вашего контента — со всеми последствиями для поведения людей и для ранжирования.
Частые вопросы
Почему просто не убрать интро совсем?
Потому что на десктопе оно работает: это часть впечатления от продукта, и мы не считаем правильным платить продуктом за метрику. Задача была не «убрать анимацию», а «сделать так, чтобы она не мешала тем, кому её не показывают». После починки на десктопе не изменилось ничего.
Это проблема Next.js или framer-motion?
Ни то, ни другое. Оба инструмента делают ровно то, что обещают. Ошибка архитектурная: условие, от которого зависит видимость контента, оказалось доступно только после гидратации. Такое повторяется на любом стеке с серверным рендером — React, Vue, Svelte без разницы.
Насколько это вообще важно для бизнеса?
LCP — один из трёх Core Web Vitals, а они входят в сигналы ранжирования Google и считаются по реальным пользователям, в основном мобильным. Но ещё до всякого поиска: шесть секунд тёмного экрана — это люди, которые ушли обратно в выдачу, не увидев ни строчки. Деньги теряются раньше, чем позиции.
Почему вы публикуете разбор собственной ошибки?
По двум причинам. Во-первых, эту ошибку легко повторить, а описания её мы не нашли — так что материал полезен. Во-вторых, разбор своего промаха с числами проверяется за пять минут: откройте наш сайт и прогоните Lighthouse сами. Кейс, который можно проверить, стоит дороже кейса, который нужно принять на веру.
Источники
Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.
- 01web.dev — Largest Contentful Paint (LCP) — определение метрики и разбор её на составляющие, включая element render delay
- 02Chrome for Developers — Lighthouse performance scoring — как считается итоговая оценка и чем отличаются режимы троттлинга
Материалы пишут инженеры, работающие на проектах, — но без подписи именем. Причина та же, по которой на сайте нет логотипов клиентов: почти все проекты идут под NDA или white-label, и авторство статьи о платёжном ядре указывает на заказчика не хуже логотипа. Взамен мы отвечаем за текст правилами, а не именами — кроме материалов о собственном открытом коде: те подписаны автором.
Как мы пишем и что проверяем