Dostępność Design

Ruch, który szanuje ludzi: prefers-reduced-motion i przycisk pauzy

Jak ta strona obchodzi się z ruchem: pauza zgodna z WCAG 2.2.2, prefers-reduced-motion, pętle stające poza ekranem i wejścia, które nie ukrywają treści.

Autor: Mateusz Muszarski Zaktualizowano 6 min czytania In English

Ruch, który szanuje ludzi, robi cztery rzeczy. Przy prefers-reduced-motion pokazuje stan końcowy, daje jeden zapamiętywany przycisk pauzy dla każdej pętli, zatrzymuje pętle, których nikt nie widzi, i nie ukrywa treści, gdy skrypt zawiedzie. Ta strona robi wszystkie cztery jednym małym modułem i jednym przyciskiem.

TL;DR

  • Dwa kryteria: WCAG 2.2.2 (poziom A) wymaga sposobu na wstrzymanie ruchu, który startuje sam i trwa dłużej niż 5 s; 2.3.3 (poziom AAA) wymaga, żeby ruch wywołany interakcją dało się wyłączyć.
  • Jeden przełącznik: klasa na <html> wstrzymuje każdą pętlę w CSS i JavaScripcie, trafia do localStorage i działa przed pierwszym malowaniem; przy reduced motion wszystko od razu pokazuje stan końcowy.
  • Nic nie znika: pętle stają poza ekranem, animacje wejścia ukrywają treść dopiero wtedy, gdy ich skrypt działa, a H1 (element LCP) nigdy nie animuje się przy ładowaniu.

Które kryteria WCAG dotyczą ruchu?

2.2.2 Pause, Stop, Hide ma poziom A, czyli podstawowy. Ruchoma, migająca albo przewijana treść, która startuje sama, trwa dłużej niż pięć sekund i stoi obok innej treści, musi dać się wstrzymać, zatrzymać albo ukryć. Dokument „Understanding” tłumaczy tę liczbę: pięć sekund wystarczy, żeby przyciągnąć uwagę, ale da się je przeczekać. Zaleca też jedną kontrolkę dla wszystkiego, co się rusza.

2.3.3 Animation from Interactions ma poziom AAA: animację ruchu wywołaną interakcją musi dać się wyłączyć, chyba że jest niezbędna dla działania albo przekazywanej informacji. Chroni osoby z zaburzeniami przedsionkowymi (ucha wewnętrznego), u których, jak pisze W3C, ruch potrafi wywołać nudności, migrenę, a nawet potrzebę leżenia w łóżku, żeby dojść do siebie. Jedną z technik (C39) jest zapytanie medialne prefers-reduced-motion.

To dwa różne przełączniki. 2.2.2 dotyczy uwagi, i to u każdego: pasek przewijający się kątem oka, kiedy próbujesz czytać. 2.3.3 dotyczy ciała. Ustawienie w systemie pomaga tym, którzy o nim wiedzą; widoczny przycisk pomaga całej reszcie, także komuś na pożyczonym laptopie. Dlatego robię jedno i drugie.

Co się rusza na tej stronie?

Sam pierwszy ekran strony głównej ma trzy pętle:

Pierwszy ekran
Akordeon liter w moim imieniu i nazwisku (idzie za kursorem), obracająca się naklejka i pasek narzędzi, w oknie szerokim na 1200 px. Jak każda pętla na tej stronie, ten klip staje po pauzie i przy reduced motion.

Pełna lista i to, co robi z nią każda kontrolka:

Ruch Reduced motion Przycisk pauzy Poza ekranem
Pasek z narzędziami (pętla 50 s) nie startuje; lista układa się w wiersze wstrzymany wstrzymany
Obracająca się naklejka (pętla 22 s) kończy się od razu wstrzymana wstrzymana
Akordeon liter w moim imieniu i nazwisku nie startuje łagodnie wraca i staje staje
Sekcje, które przechodzą dalej same (Jak pracuję, AI, Kompetencje) stoją, scena narysowana w całości nie przechodzą dalej nie przechodzą dalej
Ołówek rysujący karteczki z wpisami karteczki po prostu są karteczki po prostu są staje
Wideo projektów plakat z przyciskiem odtwarzania wstrzymane wstrzymane
Pojawianie się sekcji przy przewijaniu treść po prostu jest treść po prostu jest nie dotyczy

Wszystko to dekoracja. Nic na stronie nie potrzebuje ruchu, żeby mieć sens, i dlatego „zatrzymaj wszystko” jest bezpiecznym wyborem.

Co pokazać przy reduced motion?

Stan końcowy, a nie brak stanu. MDN opisuje to ustawienie jako prośbę o interfejs, który usuwa, ogranicza albo zastępuje animacje oparte na ruchu. Gdzie się je włącza, według MDN (nazwy menu w moim tłumaczeniu, w systemie mogą brzmieć trochę inaczej):

  • Windows 11: Ustawienia, Ułatwienia dostępu, Efekty wizualne, Efekty animacji
  • macOS Sequoia (15) i starsze: Ułatwienia dostępu, Monitor, Ogranicz ruch
  • macOS Tahoe i nowsze: Ułatwienia dostępu, Ruch, Ogranicz ruch
  • iOS: Dostępność, Ruch
  • Android 9 i nowszy: Ułatwienia dostępu, Usuń animacje

Część CSS to jeden blok:

@media (prefers-reduced-motion: reduce) {
  *, ::before, ::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Animacje nadal się wykonują, przez setną część milisekundy, więc każdy element ląduje w stanie końcowym, zamiast utknąć na starcie. Przejścia też kończą się od razu, jak uniesienie przycisku z wpisu o efektach hover na telefonach. Przejścia między stronami (view transitions) istnieją tylko w prefers-reduced-motion: no-preference.

Jest jeden haczyk: ten blok nie rusza animation-delay. Wejście z both nadal stoi na pierwszej klatce przez cały czas opóźnienia, dlatego rysowane sceny na tej stronie przy reduced motion wyłączają animacje całkiem.

Część JavaScript sprawdza to samo zapytanie, zanim cokolwiek uruchomi. Akordeon liter nie startuje, a pasek nie kopiuje listy, więc narzędzia stoją w zwykłych wierszach:

Strona główna na telefonie: pasek narzędzi to jeden wiersz, ucięty z obu stron, przesuwający się w bok Ruch włączony
Ta sama strona przy reduced motion: wszystkie narzędzia stoją w zawiniętych wierszach, żadne nie jest ucięte Reduced motion
Pasek narzędzi na telefonie szerokim na 390 px, normalnie i z emulacją reduced motion (Chromium, 8 października 2026). Stan końcowy to lista, którą da się przeczytać w całości, a nie zamrożony pasek.

Przycisk pauzy też znika. Skoro nic się nie rusza, byłby tylko szumem.

Jak jeden przycisk zatrzymuje wszystko?

Stan pauzy to jedna klasa na <html>. CSS ją czyta, a pętle w JavaScripcie pytają wspólną funkcję.

export const motionStill = () => reduce.matches || root.classList.contains("still");

export function setPaused(paused: boolean) {
  root.classList.toggle("still", paused);
  try {
    if (paused) localStorage.setItem("motion", "still");
    else localStorage.removeItem("motion");
  } catch {
    // storage zablokowany: wybór działa tylko na tej stronie
  }
  notify(); // pętle zapisane przez onMotion() reagują od razu
}
.still *, .still *::before, .still *::after {
  animation-play-state: paused !important;
}

Szczegóły, które mają znaczenie:

  • Jedna reguła obejmuje każdą animację CSS, także te dodane później. Pętle w JavaScripcie sprawdzają motionStill() w każdej klatce albo kroku, więc akordeon liter łagodnie wraca do spoczynku i staje.
  • To prawdziwy przycisk z aria-pressed, w menu i w stopce, a wszystkie kopie są zsynchronizowane. Etykieta zmienia się z „Zatrzymaj animacje” na „Wznów animacje”.
  • Jest zapamiętany. Malutki skrypt inline w <head> stosuje zapisany wybór przed pierwszym malowaniem, więc kolejna strona nie zaczyna się ruszać tylko po to, żeby za chwilę stanąć.
  • Ruch wraca tam, gdzie stanął. Według MDN wznowiona animacja rusza od miejsca, w którym ją wstrzymano, a 2.2.2 to dopuszcza. Akordeon wraca łagodnie przez 1,6 s.

Test w Playwright klika przycisk w stopce, sprawdza, czy pasek stoi, otwiera polską stronę główną i sprawdza, czy pauza nadal działa.

Po co zatrzymywać to, czego nikt nie widzi?

MDN wymienia ten przypadek użycia Intersection Observer wprost: decydowanie, czy wykonywać zadania albo animacje, zależnie od tego, czy użytkownik zobaczy wynik. Tutaj każda sekcja startuje wstrzymana, a skrypt oznacza te, które są blisko widoku:

const sections = new IntersectionObserver(
  (entries) => entries.forEach((e) => e.target.classList.toggle("near", e.isIntersecting)),
  { rootMargin: "80px 0px" },
);
for (const section of document.querySelectorAll(".sec")) sections.observe(section);
.js .sec:not(.near) *,
.still * {
  animation-play-state: paused !important;
}

Margines 80 px wznawia pętlę tuż przed wjazdem sekcji na ekran, a pierwsza sekcja ma near już w HTML-u, więc pierwszy ekran nie czeka na skrypt.

Pierwsza wersja działała odwrotnie: oznaczała klasą off każdą sekcję poza ekranem. Przy ładowaniu przeliczało to style 1658 elementów, około 330 ms pracy głównego wątku w profilu wydajności z czterokrotnym spowolnieniem procesora (7 października). Start od pauzy oznacza, że zmieniają się tylko sekcje w widoku. Pętle w rozdziałach projektów czekają jeszcze na Twój pierwszy ruch.

Co, jeśli skrypt animacji wejścia zawiedzie?

Typowy wzorzec „pokaż przy przewijaniu” ukrywa elementy w CSS, a skrypt odsłania je, gdy wjeżdżają na ekran. Jeśli skrypt zostanie zablokowany, rzuci błąd albo dotrze późno, treść zostaje niewidoczna. Tutaj stan ukryty istnieje dopiero wtedy, gdy skrypt sam zgłosi, że działa:

.rv-ready:not(.still) .rv:not(.in) {
  opacity: 0;
  transform: translateY(1.5rem);
}

Klasę rv-ready na <html> dodaje sam skrypt, kiedy już zaczął obserwować. Nie ma skryptu, nie ma ukrytej treści. Pauza też wyłącza stan ukryty.

Dlaczego nagłówek nigdy nie animuje się przy ładowaniu?

H1 na stronie głównej to moje imię i nazwisko, a zarazem element Largest Contentful Paint. Przewodnik Google na web.dev wyklucza z LCP elementy z przezroczystością 0, więc wejście z opacity: 0 każe LCP czekać na koniec animacji. To też pierwsza rzecz, którą ktokolwiek czyta.

Dlatego imię i nazwisko są gotowe przy pierwszym malowaniu (dopasowane do kontenera w czasie builda). Tekst pod nimi też jest widoczny od razu i tylko przesuwa się o kilka pikseli na swoje miejsce, w samym CSS. Animacje przy przewijaniu zaczynają się poniżej pierwszego ekranu.

Czy wstrzymane wejście może zamarznąć na pierwszej klatce?

Może, i tutaj się to zdarzyło. animation-fill-mode: backwards (albo both) stosuje pierwszą klatkę kluczową w czasie opóźnienia, a animation-play-state: paused trzyma animację tam, gdzie jest. 5 października zapisana pauza, zastosowana przed pierwszym malowaniem, zatrzymała wejścia na pierwszej klatce: niewidocznych.

Pauza powinna przeskakiwać wejścia do końca, a nie zamrażać je na starcie. Wejścia mają tu swoją klasę, a pauza je wyłącza:

.still :is(.rise, .enter) {
  animation: none !important;
}

Test w Playwright zapisuje teraz pauzę, ładuje stronę główną i sprawdza, czy tekst w nagłówku i naklejka są w pełni widoczne.

Co trafia na listę kontrolną?

  • Jeden prawdziwy <button> z aria-pressed wstrzymuje każdą pętlę dłuższą niż 5 s (2.2.2).
  • Wybór jest zapisany, działa przed pierwszym malowaniem i przetrwa zablokowany storage.
  • Reduced motion pokazuje stany końcowe: CSS kończy się od razu, pętle w JavaScripcie nie startują.
  • Pętle stają poza ekranem, a pętle klatek przestają się planować, gdy nic się nie dzieje.
  • Animacje przy przewijaniu ukrywają treść dopiero wtedy, gdy ich skrypt już działa.
  • Element LCP nigdy nie pojawia się z animacją.
  • Testy przeładowują stronę z zapisaną pauzą i emulują reduced motion (page.emulateMedia({ reducedMotion: "reduce" })), a potem sprawdzają to, co widzi czytelnik.

Ograniczenia

  • Nie mierzyłem oszczędności procesora ani baterii z zatrzymywania pętli poza ekranem. Ten argument nie potrzebuje liczby: ruch, którego nikt nie widzi, nie ma powodu działać.
  • Reduced motion testuję emulacją w Playwright, a nie prawdziwym ustawieniem w każdym systemie.
  • Ścieżki ustawień pochodzą z MDN; nie sprawdziłem ich sam w każdym systemie.

Resztę budowy opisałem w studium przypadku tej strony. Żeby wypróbować przycisk, otwórz menu albo zjedź do stopki strony z moimi projektami i kliknij „Zatrzymaj animacje”.

Źródła

  1. Understanding SC 2.2.2: Pause, Stop, Hide (W3C WAI, WCAG 2.2) (otwiera się w nowej karcie) (dostęp: )
  2. Understanding SC 2.3.3: Animation from Interactions (W3C WAI, WCAG 2.2) (otwiera się w nowej karcie) (dostęp: )
  3. prefers-reduced-motion (MDN) (otwiera się w nowej karcie) (dostęp: )
  4. Intersection Observer API (MDN) (otwiera się w nowej karcie) (dostęp: )
  5. animation-play-state (MDN) (otwiera się w nowej karcie) (dostęp: )
  6. animation-fill-mode (MDN) (otwiera się w nowej karcie) (dostęp: )
  7. Largest Contentful Paint (LCP), web.dev (otwiera się w nowej karcie) (dostęp: )

Wszystkie wpisy