Состояния загрузки в CSS и JavaScript: чек-лист
Спиннер по центру экрана — минимальный уровень. Скелетоны, оптимистичные обновления, обработка ошибок и защита от мигания дают ощутимо другое качество интерфейса. Разбираем по пунктам.

Пользователь нажал кнопку «Сохранить», интерфейс замер, кнопку можно нажать ещё раз, и уходит второй запрос. Или наоборот: данные пришли за 40 мс, но спиннер честно провисел свои 300 мс с задержкой, и картинка дёрнулась. Состояния загрузки — это не «показать крутилку». Это набор решений про то, что видит пользователь между действием и результатом, и что происходит, когда результат — ошибка.
Ниже — практический чек-лист для веб-интерфейсов: что должно быть у каждого асинхронного действия, чем платит каждый подход и где он ломается. Примеры на ванильном JavaScript и CSS без фреймворков — логика переносится в React, Vue и любой другой стек. Актуально для браузеров с поддержкой fetch, AbortController и CSS-переменных, то есть практически для всех, кроме совсем старых.
Четыре состояния вместо двух
Типичная ошибка — мыслить в двух состояниях: «грузится» и «готово». На практике асинхронное действие всегда проходит через четыре:
- idle — ничего не происходит, интерфейс в покое;
- pending — запрос ушёл, ответа нет;
- success — данные пришли, показываем результат;
- error — запрос упал, показываем ошибку и способ повторить.
Если в коде нет явной ветки error, значит при первой же сетевой ошибке пользователь увидит вечный спиннер. Это самый частый дефект: разработчик тестировал на быстром локальном бэкенде, где запросы не падают.
Спиннер без таймаута и без обработки ошибки — это не индикатор загрузки, а индикатор того, что интерфейс завис навсегда. Разница только в анимации.
Чек-лист по каждому действию
- Кнопка блокируется на время запроса, чтобы не отправить его дважды.
- Есть визуальный признак pending — спиннер, скелетон или изменение самой кнопки.
- Есть ветка ошибки с текстом и кнопкой «Повторить».
- Индикатор не мигает при быстром ответе.
- Индикатор не появляется мгновенно при задержке в пару десятков миллисекунд.
- Устаревший ответ не перезаписывает свежий (гонка запросов).
- Скринридер узнаёт о смене состояния через
aria-busyилиaria-live.
Блокировка кнопки: дизейбл или флаг
Простейший способ не отправить запрос дважды — заблокировать элемент. Но disabled у кнопки убирает её из фокуса и не читается частью скринридеров как «идёт обработка». Для доступности лучше не выключать кнопку полностью, а помечать состояние атрибутом.
// Ванильный JS, современные браузеры
async function submitForm(form, url) {
const button = form.querySelector('button[type="submit"]');
// Защита от повторного клика: пока идёт запрос, выходим
if (button.dataset.pending === 'true') return;
button.dataset.pending = 'true';
button.setAttribute('aria-busy', 'true');
button.textContent = 'Сохраняем…';
try {
const response = await fetch(url, {
method: 'POST',
body: new FormData(form),
});
if (!response.ok) {
throw new Error('Сервер ответил ' + response.status);
}
button.textContent = 'Сохранено';
} catch (err) {
// Показываем ошибку рядом с формой, а не в alert
showError(form, err.message);
button.textContent = 'Повторить';
} finally {
// finally выполнится и при успехе, и при ошибке
button.dataset.pending = 'false';
button.removeAttribute('aria-busy');
}
}Атрибут aria-busy="true" сообщает скринридеру, что область обновляется. Флаг в dataset.pending защищает от двойной отправки, не трогая фокус.
Спиннер против скелетона
Спиннер уместен для действий («сохраняем», «отправляем»), где нет заранее известной структуры результата. Скелетон — серые заглушки в форме будущего контента — лучше для загрузки данных с известной раскладкой: карточки, таблицы, профиль. Скелетон снижает воспринимаемое время ожидания, потому что верстка не прыгает при появлении данных.
| Критерий | Спиннер | Скелетон |
|---|---|---|
| Когда уместен | Действия, короткие запросы | Загрузка структурированного контента |
| Сдвиг макета (CLS1) | Возможен при появлении данных | Нет, если размеры совпадают |
| Стоимость реализации | Низкая | Средняя: нужна разметка-заглушка |
| Ощущение скорости | Нейтральное | Быстрее при одинаковом времени |
Скелетон на чистом CSS — это блоки с фоновым градиентом и анимацией сдвига:
/* CSS. Работает во всех современных браузерах */
.skeleton {
background: linear-gradient(
90deg,
#eee 25%,
#f5f5f5 50%,
#eee 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Уважаем настройку пользователя: без анимации */
@media (prefers-reduced-motion: reduce) {
.skeleton { animation: none; }
}Медиазапрос prefers-reduced-motion обязателен: мигающие анимации мешают людям с вестибулярными нарушениями. Это не опция «на будущее», а часть корректной реализации.
Защита от мигания
Две противоположные проблемы, обе портят впечатление:
- Мигание. Ответ пришёл за 50 мс, спиннер успел моргнуть и исчезнуть. Глаз ловит дёрганье.
- Ложное ожидание. Спиннер показали через 400 мс после старта, хотя ответ уже был на подходе.
Приём: показывать индикатор с задержкой (например, 150 мс — если ответ придёт раньше, спиннер вообще не появится) и гарантировать ему минимальное время жизни (например, 400 мс — чтобы он не моргнул).
// Индикатор с задержкой появления и минимальным временем показа
function createDelayedSpinner(showDelay = 150, minVisible = 400) {
let showTimer = null;
let shownAt = 0;
return {
start(el) {
showTimer = setTimeout(() => {
el.hidden = false;
shownAt = Date.now();
}, showDelay);
},
stop(el) {
clearTimeout(showTimer);
// Если спиннер ещё не показан — просто выходим
if (!shownAt) {
el.hidden = true;
return;
}
const elapsed = Date.now() - shownAt;
const remaining = Math.max(0, minVisible - elapsed);
setTimeout(() => {
el.hidden = true;
shownAt = 0;
}, remaining);
},
};
}Числа 150 и 400 — не догма. Подбирайте под реальные тайминги вашего API: если 95-й перцентиль ответа меньше 150 мс, спиннер почти никогда не понадобится.
Гонка запросов: устаревший ответ побеждает свежий
Классический баг живого поиска: пользователь печатает «айфон», уходят запросы на «ай», «айф», «айфон». Ответ на «ай» приходит последним из-за сети — и в списке результаты для «ай». Решение — отменять предыдущий запрос через AbortController.
// Отмена устаревшего запроса
let activeController = null;
async function search(query, renderResults) {
// Отменяем предыдущий незавершённый запрос
if (activeController) activeController.abort();
activeController = new AbortController();
try {
const response = await fetch(
'/api/search?q=' + encodeURIComponent(query),
{ signal: activeController.signal }
);
const data = await response.json();
renderResults(data);
} catch (err) {
// AbortError — это не настоящая ошибка, а наша отмена
if (err.name !== 'AbortError') {
showError(err.message);
}
}
}Ветка с проверкой err.name !== 'AbortError' обязательна: без неё каждая отмена будет показываться пользователю как сетевая ошибка.
Доступность состояний
Зрячий пользователь видит спиннер. Пользователь скринридера — нет, если состояние не объявлено. Минимум:
- Контейнер с загружаемым контентом помечается
aria-busy="true"на время pending. - Сообщение об ошибке живёт в области с
role="alert"илиaria-live="assertive"— тогда скринридер прочитает его сразу. - Кнопка «Повторить» — настоящая
<button>, а не<div>с обработчиком, иначе она недоступна с клавиатуры.
Что делать дальше
Соберите в проекте одну обёртку для асинхронных действий, которая закрывает весь чек-лист сразу: блокировку, задержку индикатора, отмену, ветку ошибки и aria-атрибуты. Тогда каждое новое действие получит корректную обработку бесплатно, а не будет собираться заново с теми же дефектами.
Дальше стоит замерить реальные тайминги ваших запросов в проде — Web Vitals покажут INP и CLS, по ним видно, где интерфейс дёргается и где ожидание слишком долгое без индикации. Инвалидация и кеширование данных — отдельная тема, здесь рассматриваем только визуальные состояния.
1 CLS (Cumulative Layout Shift) — метрика неожиданного сдвига макета: насколько сильно контент прыгает, когда подгружаются данные, шрифты или картинки. Высокий CLS раздражает и штрафуется в оценке качества страницы.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: AbortController — MDN Web Docs, prefers-reduced-motion — MDN Web Docs
Частые вопросы
Стоит ли блокировать кнопку через disabled или лучше через флаг?
А если у меня React или Vue, а не ванильный JS?
Спиннер моргает при быстрых ответах — что делать?
AbortController не поддерживается в моём окружении, что делать?
Как убедиться, что состояния корректно работают в проде?
Нужен ли prefers-reduced-motion, если анимация лёгкая?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.