Модуль 7 · урок 2 з 10
Скелетони і спінери
«Loading skeletons/spinners to represent asynchronous service delay» — перший пункт цілі. Затримка в мок-сервісі з модуля 4 була закладена саме заради цього уроку: без неї перевірити стан завантаження неможливо.
Скелетон чи спінер
| Скелетон | Спінер | |
|---|---|---|
| Що показує | Форму майбутнього вмісту | Що йде процес |
| Коли доречний | Відома структура: список, картка, таблиця | Структура невідома або дія точкова |
| Типовий випадок | Перше завантаження екрана | Кнопка «Зберегти», модалка |
| Відчуття | «Зараз буде» | «Щось відбувається» |
Правило просте: якщо ти знаєш, як виглядатиме вміст — малюй його сірими прямокутниками. Якщо не знаєш або чекати менш ніж секунду — спінер.
Скелетон має повторювати макет
<div class="skeleton-row" aria-hidden="true">
<span class="sk" style="width: 120px"></span>
<span class="sk" style="width: 100%"></span>
<span class="sk" style="width: 80px"></span>
</div>
.sk {
display: block;
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg,
var(--color-surface-2) 25%,
var(--color-rule) 37%,
var(--color-surface-2) 63%);
background-size: 400% 100%;
animation: sk 1.4s ease infinite;
}
@keyframes sk {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
@media (prefers-reduced-motion: reduce) {
.sk { animation: none; } /* обовʼязково */
}
Ті самі розміри, що й у справжнього вмісту. Інакше при появі даних макет стрибне — і це дратує сильніше, ніж очікування.
aria-hidden="true". Скелетон — декорація. Скрінрідер не має читати
порожні прямокутники; замість цього йому потрібне повідомлення про завантаження
(урок про живі області).
Повага до prefers-reduced-motion. Для частини людей рухома анімація
викликає фізичний дискомфорт. Один медіазапит — і проблеми немає.
Скільки рядків малювати
@for (i of skeletonRows; track i) {
<app-skeleton-row />
}
// стільки, скільки поміститься на екрані — не більше й не менше
protected readonly skeletonRows = Array.from({ length: 8 }, (_, i) => i);
Три рядки виглядають так, ніби дані вже прийшли й їх мало. Пʼятдесят — як помилка рендеру. Орієнтир — приблизна кількість видимих рядків.
Затримка перед показом
// якщо відповідь прийшла за 80 мс, скелетон встигне блимнути — і це виглядає як глюк
readonly showSkeleton = toSignal(
this.loading$.pipe(
switchMap(loading => loading ? timer(200).pipe(map(() => true)) : of(false)),
),
{ initialValue: false },
);
Двісті мілісекунд — усталений поріг: швидші відповіді проходять непомітно, повільніші показують скелетон. Це прибирає найнеприємніший ефект — коротке блимання при швидкому кеші чи локальному моці.
Точкові індикатори
<button type="submit" [disabled]="saving()">
@if (saving()) {
<span class="spinner" aria-hidden="true"></span>
Збереження…
} @else {
Зберегти
}
</button>
Для кнопки правильна поведінка — не спінер поверх усього екрана, а зміна самої кнопки: вона блокується й повідомляє про стан. Користувач бачить, що його дію прийнято, і не тисне ще раз.
- Скелетон формою майбутнього вмісту.
- Затримка 200 мс перед показом.
- Оновлення — тонка смужка, а не повний скелетон.
prefers-reduced-motionвимикає анімацію.
- Спінер на весь екран для будь-якої дії.
- Скелетон іншої висоти, ніж контент — макет стрибає.
- Порожній екран без жодного індикатора.
- Скелетон, який читає скрінрідер.