Скелетоны и состояния загрузки: рабочие приёмы из практики
Спиннер по центру экрана и layout shift на весь список — две самые частые ошибки в состояниях загрузки. Разбираем, когда скелетон помогает, когда мешает и как не сломать разметку при подмене плейсхолдера на данные.

Пользователь открывает страницу товара. Сначала пустой экран, потом на 400 мс появляется спиннер, потом резко прыгает контент, потому что картинка загрузилась и сдвинула цену вниз. Так делать не надо, но именно так делают по умолчанию. Скелетон — не украшение, а инструмент управления восприятием задержки и стабильностью разметки. Разберём, где он реально работает, где вредит и какие приёмы переживают продакшн.
Спиннер, скелетон, ничего — когда что
Три состояния загрузки решают разные задачи, и путать их дорого.
- Спиннер уместен, когда вы не знаете структуру будущего контента и загрузка короткая. Например, кнопка отправки формы: нажали — крутится, пришёл ответ — исчезла. Форма разметки от вас не зависит.
- Скелетон уместен, когда структура известна заранее: карточка товара, лента постов, таблица. Вы рисуете серые прямоугольники ровно там, где будут заголовок, цена, изображение. Пользователь считывает форму страницы до прихода данных.
- Ничего (пустой контейнер) уместно, если ожидаемая задержка меньше 200-300 мс. Мигающий на 100 мс скелетон раздражает сильнее, чем короткая пустота.
Скелетон продаёт не скорость, а предсказуемость. Пользователь видит, что грузится именно карточка, а не сломанная страница, и готов подождать на секунду дольше.
Главная ошибка: скелетон не совпадает с реальной разметкой
Если серый прямоугольник заголовка высотой 24 px, а настоящий заголовок занимает две строки и 52 px, при подмене происходит layout shift1 — контент под ним прыгает. Скелетон должен занимать те же размеры, что и финальный контент, иначе он вредит вместо помощи. На практике это значит: скелетон верстается тем же компонентом-обёрткой, что и данные, с зарезервированной высотой.
Резервирование места вместо угадывания высоты
Основной источник дёрганья — изображения без заданных размеров и текст переменной длины. С изображениями лечится атрибутами или CSS.
/* Резервируем место под изображение через соотношение сторон.
Работает во всех актуальных браузерах. */
.product-image {
aspect-ratio: 4 / 3;
width: 100%;
object-fit: cover;
background: #eee; /* фон, пока картинка не пришла */
}
Пока изображение грузится, блок уже занимает высоту, и подмена не сдвигает соседей. С переменным текстом сложнее: точную высоту заранее не знаете. Здесь помогает ограничение числа строк скелетона под ожидаемый максимум и line-clamp на реальном тексте, чтобы длинный заголовок не расползся.
CSS-скелетон без картинок и библиотек
/* Пульсирующий плейсхолдер строки текста */
.skeleton-line {
height: 1em;
border-radius: 4px;
background: linear-gradient(
90deg,
#ececec 25%,
#f5f5f5 37%,
#ececec 63%
);
background-size: 400% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
/* Уважаем настройку пользователя: без анимации, если он её отключил */
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
}
}
Медиазапрос prefers-reduced-motion часто забывают. Мерцающий шиммер у людей с вестибулярной чувствительностью вызывает дискомфорт — отключение анимации здесь не опция, а базовая доступность.
Как не показать скелетон на 50 миллисекунд
Если данные пришли из кеша мгновенно, скелетон успевает моргнуть — визуальный мусор. Приём: показывать скелетон только если загрузка длится дольше порога. Задержка перед показом плюс минимальное время жизни, чтобы не было и обратной проблемы — скелетон мигнул на 30 мс и пропал.
// React 18, хук для отложенного показа состояния загрузки.
// Скелетон появится, только если isLoading держится дольше delay,
// и после появления проживёт минимум minVisible.
import { useEffect, useRef, useState } from 'react';
export function useDelayedLoading(isLoading, { delay = 300, minVisible = 500 } = {}) {
const [show, setShow] = useState(false);
const shownAt = useRef(0);
useEffect(() => {
let showTimer;
let hideTimer;
if (isLoading) {
showTimer = setTimeout(() => {
shownAt.current = Date.now();
setShow(true);
}, delay);
} else if (show) {
// не прячем раньше, чем пройдёт minVisible с момента показа
const elapsed = Date.now() - shownAt.current;
const wait = Math.max(0, minVisible - elapsed);
hideTimer = setTimeout(() => setShow(false), wait);
}
return () => {
clearTimeout(showTimer);
clearTimeout(hideTimer);
};
}, [isLoading, show, delay, minVisible]);
return show;
}
Конкретные пороги подбирайте под ваш трафик. 300 мс перед показом и 500 мс минимальной жизни — разумная стартовая точка, но если у вас медленный бэкенд, порог показа стоит опустить.
Три подхода к отрисовке скелетона
| Подход | Плюс | Минус |
|---|---|---|
| Отдельные компоненты-скелетоны | Полный контроль над размерами | Дублирование: скелетон и контент рассинхронизируются при рефакторинге |
Один компонент, режим loading | Разметка гарантированно совпадает | Компонент раздувается условиями |
| Библиотека (react-content-loader, MUI Skeleton) | Быстрый старт, готовые формы | Свои размеры всё равно задавать вручную, плюс вес зависимости |
На проектах, где разметку карточки трогают часто, выигрывает второй подход: скелетон живёт в том же компоненте и не может разъехаться с данными. Для быстрых прототипов — библиотека. Отдельные компоненты оправданы, когда скелетон принципиально проще финального блока.
Что учесть про доступность
- Контейнер с загрузкой помечайте
aria-busy="true", пока данные не пришли — скринридер не станет зачитывать серые прямоугольники как контент. - Сами скелетон-блоки лучше скрыть от ассистивных технологий: они не несут смысла. Декоративная разметка не должна попадать в дерево доступности.
- Ошибку загрузки объявляйте через
role="alert"илиaria-live, иначе пользователь скринридера останется в тишине после падения запроса.
Ошибка и пустота — тоже состояния
Скелетон отвечает только за «грузится». В реальном компоненте состояний как минимум четыре: загрузка, успех с данными, успех с пустым результатом, ошибка. Пропуск пустого состояния — частый баг: запрос вернул пустой массив, скелетон исчез, а на его месте ничего. Пользователь видит белый экран и думает, что сломалось.
Порядок проверки в компоненте: сначала ошибка, потом загрузка, потом пустой результат, и только в конце — рендер данных. Если проверять загрузку первой, при повторном запросе поверх уже показанных данных снова моргнёт скелетон и сотрёт то, что пользователь уже видел.
Что настроить дальше
Померьте Cumulative Layout Shift на реальных страницах — Lighthouse или поле CLS в Web Vitals покажут, где скелетон не совпадает с контентом по высоте. Если CLS выше 0.1, ищите блоки без зарезервированной высоты. Дальше стоит посмотреть в сторону React Suspense и streaming SSR: там состояние загрузки перестаёт быть ручным флагом и становится частью рендера. Инвалидация и повторные запросы поверх показанных данных — отдельная тема, здесь мы разбирали только первичную загрузку.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: MDN: prefers-reduced-motion, web.dev: Cumulative Layout Shift (CLS)
Частые вопросы
А если у меня не React, а Vue или ванильный JS?
Скелетон появляется и сразу пропадает — как убрать мигание?
Контент прыгает при подмене скелетона на данные, что делать?
Нужна ли библиотека или хватит CSS?
Скелетон вредит доступности?
Что показывать, если запрос вернул пустой результат?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.