Angular IDPStrong Junior
🔥 0

Модуль 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 вимикає анімацію.
Погано
  • Спінер на весь екран для будь-якої дії.
  • Скелетон іншої висоти, ніж контент — макет стрибає.
  • Порожній екран без жодного індикатора.
  • Скелетон, який читає скрінрідер.