Скелетоны и состояния загрузки: чек-лист перед продом
Скелетон-заглушка выглядит красиво в Storybook и разваливается на реальных данных: прыгает лейаут, крутится вечный спиннер, при ошибке пусто. Разбираем, что проверить до релиза.

Скелетон на демо-данных выглядит идеально: серые прямоугольники ровной высоты, плавное мерцание, всё на месте. Потом приходит боевой ответ API на 700 мс медленнее, у пользователя товар без картинки и имя в две строки — и лейаут прыгает на 40 пикселей вниз, страница дёргается, метрика CLS уезжает в красную зону. Ниже — чек-лист состояний загрузки, которые ломаются на реальных данных, и как их проверить до того, как это увидит пользователь.
Материал про приёмы и типичные ошибки: не как сделать первый скелетон, а что в нём чаще всего забывают. Примеры на React 18.x и CSS, но принципы одинаковы для Vue, Svelte и любого SSR-фреймворка. Тему инвалидации кеша данных не трогаем — здесь только про то, что видит глаз, пока данные едут.
Четыре состояния, а не два
Самая частая ошибка — мыслить бинарно: «грузится / загрузилось». Реальный экран живёт минимум в четырёх состояниях, и три из них обычно недоделаны.
- loading — данные едут, показываем скелетон;
- success с данными — обычный рендер;
- success без данных — запрос прошёл, но список пустой. Скелетон закончился, а показывать нечего. Нужен явный empty state, а не белый экран;
- error — запрос упал. Если тут показать тот же скелетон, он будет мерцать вечно.
Проверка простая: заглушите бэкенд и посмотрите, что видит пользователь при таймауте и при пустом ответе. Если в обоих случаях бесконечное мерцание — состояний у вас два, а не четыре.
Вечный скелетон хуже честной ошибки: пользователь ждёт, потом перезагружает страницу, потом уходит. Спиннер, который крутится минуту, — это не «загрузка», это сломанный экран, который просто не признаётся.
Скелетон должен совпадать по размеру с контентом
Главная причина прыжков лейаута — скелетон и реальный блок разной высоты. Пока грузится, стоит заглушка 60px, приходит карточка на 96px, всё содержимое ниже подпрыгивает. Это прямой вклад в Cumulative Layout Shift — метрику, которую Google учитывает в Core Web Vitals.
Правило: скелетон резервирует ровно те же размеры, что займёт контент. Для карточек с картинкой фиксированного размера это делается через aspect-ratio, а не через угаданную высоту в пикселях.
/* Резервируем место под изображение до его загрузки.
aspect-ratio держит высоту, картинка не сдвинет лейаут. */
.card__image {
aspect-ratio: 3 / 4;
width: 100%;
background: #eceff1;
}
/* Скелетон текста: две строки той же высоты, что реальный текст */
.skeleton-line {
height: 1em;
margin-bottom: 0.5em;
border-radius: 4px;
background: linear-gradient(90deg, #eceff1 25%, #f5f7f8 50%, #eceff1 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
Проблема остаётся с текстом переменной длины: имя товара может занять одну строку или три. Скелетон в две строки не совпадёт ни с тем, ни с другим. Тут выбор — либо ограничить контент по высоте через line-clamp, чтобы количество строк было предсказуемым, либо смириться с небольшим сдвигом на текстовых блоках, но убрать прыжки на медиа. Медиа сдвигает лейаут заметнее, чем строка текста.
Не забудьте про prefers-reduced-motion
Мерцающая анимация shimmer у части пользователей вызывает дискомфорт, а в системных настройках у них включено «уменьшить движение». Анимацию под этим флагом надо гасить:
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
background: #eceff1;
}
}
Когда скелетон, когда спиннер, когда ничего
Скелетон уместен не везде. Три подхода — и у каждого своя цена.
| Подход | Когда подходит | Чем платите |
|---|---|---|
| Скелетон | Известна структура будущего контента: список карточек, таблица, профиль | Надо поддерживать вторую вёрстку, повторяющую реальную. Ломается при рассинхроне размеров |
| Спиннер | Структура неизвестна или единичное действие: отправка формы, догрузка по кнопке | Не резервирует место, лейаут прыгнет при появлении контента. На долгих загрузках раздражает |
| Ничего (optimistic UI) | Быстрые ответы (менее ~200 мс) или уверенность в успехе: лайк, чекбокс | При медленном ответе кажется, что клик не сработал. При ошибке нужен откат |
Практический критерий: если ответ приходит быстрее 200–300 мс, любой индикатор только мигнёт и добавит визуального шума — лучше не показывать ничего. Если ответ дольше, но структура известна — скелетон. Спиннер оставьте для действий без предсказуемого лейаута.
Порог задержки: не показывайте скелетон мгновенно
Если данные приходят за 100 мс, скелетон успеет мигнуть и пропасть — это заметнее, чем короткая пауза. Показывайте индикатор с задержкой: если за 200 мс ответа нет — рисуем скелетон, иначе ждём молча.
import { useState, useEffect } from "react";
// Возвращает true, только если загрузка длится дольше delay.
// Гасит мигание скелетона на быстрых ответах.
function useDelayedLoading(isLoading: boolean, delay = 200): boolean {
const [show, setShow] = useState(false);
useEffect(() => {
if (!isLoading) {
setShow(false);
return;
}
const timer = setTimeout(() => setShow(true), delay);
return () => clearTimeout(timer);
}, [isLoading, delay]);
return show;
}
Обратная сторона: если уж скелетон появился, держите его минимум 400–500 мс, даже когда данные пришли раньше. Иначе получите двойное мигание — скелетон вспыхнул и тут же пропал.
Доступность: экранный диктор тоже должен знать про загрузку
Скелетон — это набор пустых div, для скринридера он невидим и молчалив. Пользователь с VoiceOver не понимает, идёт загрузка или страница сломалась. Оборачивайте загружаемую область в контейнер с aria-busy и объявляйте смену состояния через живой регион.
- контейнеру со скелетоном —
aria-busy="true", после загрузки переключаете наfalse; - сами скелетон-элементы прячьте от доступности:
aria-hidden="true", иначе диктор зачитает пустоту; - сообщение об ошибке помещайте в контейнер с
role="alert"— его диктор прочитает сразу.
Что чаще всего ломается на ревью и в проде
- Скелетон и контент разъехались по размеру. Кто-то поправил высоту карточки и забыл про заглушку. Ловится только визуальным тестом или ручной проверкой с throttling.
- Нет empty state. Запрос вернул пустой массив, скелетон убрали, показывать нечего — белый экран. Проверяется отдельным моком с
[]. - Ошибка = вечный скелетон. Обработали только success, в catch забыли переключить состояние.
- Скелетон мигает на быстрых ответах. Нет порога задержки — на локальной сети всё «мигает и пропадает».
- Layout shift на реальных данных. В Storybook данные фиксированные, в проде — переменная длина имён и отсутствующие картинки.
Как проверить перед релизом
- В DevTools включите Network throttling (Slow 4G) и посмотрите загрузку в реальном темпе — прыжки лейаута видны сразу.
- В той же вкладке Network поставьте Offline и убедитесь, что показывается error state, а не вечное мерцание.
- Подмените ответ API на пустой массив — проверьте empty state.
- Подмените на данные с экстремально длинными строками и без картинок — оцените layout shift.
- Откройте вкладку Performance и посмотрите на CLS в разделе Experience: сдвиги подсвечиваются.
- Включите в системе «уменьшить движение» и убедитесь, что shimmer погас.
Что делать дальше
Настройте автоматический замер CLS в проде — например через web-vitals и отправку метрик в вашу аналитику: локальный throttling ловит не всё, реальные пользователи с медленными сетями покажут больше. Заведите в дизайн-системе скелетон как отдельный компонент рядом с реальным, чтобы правки размеров попадали в оба места. И проверьте, что каждый экран с загрузкой прогоняется хотя бы через три мока: полные данные, пустой ответ, ошибка.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Cumulative Layout Shift (CLS) — web.dev, prefers-reduced-motion — MDN
Частые вопросы
А если у меня Vue или Svelte, а не React?
Скелетон точно лучше спиннера?
Почему у меня скелетон всё равно мигает, хотя я поставил задержку?
Как поймать layout shift, если локально данные всегда одинаковые?
Обязательно ли возиться с aria-busy и role=alert?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.