css i układyanimacjedostępność

Scroll-driven animations: animation-timeline zamiast IntersectionObserver

Scroll-driven animations: animation-timeline zamiast IntersectionObserver

Klasyczny reveal on scroll wygląda tak: tworzysz IntersectionObserver, obserwujesz listę elementów, a gdy któryś przekroczy próg, dodajesz klasę .is-visible, która odpala przejście CSS. Pasek postępu jest gorszy: listener scroll przelicza scrollTop / (scrollHeight - clientHeight) i wpisuje wynik do style.width. Oba działają. Oba mają wadę, o której był artykuł o INP – logika żyje na głównym wątku, konkurując z twoimi handlerami i renderowaniem.

Ale wersja javascriptowa ma jeszcze drugi problem i to on jest ciekawszy, bo nie naprawi go żadna optymalizacja.

Dlaczego parallax w JS zawsze wygląda lekko nie tak

Nowoczesne przeglądarki przewijają na compositorze. Gdy machniesz stroną, samo przewijanie nie czeka na JavaScript – compositor przesuwa już namalowaną treść i pokazuje ci klatki, szybko, niezależnie od tego, czym akurat zajęty jest główny wątek. Dlatego strona z ciężkim JS-em nadal przewija się płynnie, choć wszystko inne się zacina.

Zdarzenie scroll trafia jednak do twojego JavaScriptu już po tym, jak to się stało. Sekwencja wygląda więc tak: przeglądarka przewija i maluje, potem informuje twój listener o nowej pozycji i dopiero wtedy twój kod może przesunąć warstwę parallaksy, żeby pasowała. Twoja warstwa zawsze reaguje na pozycję scrolla, którą użytkownik już zobaczył. Przy większej prędkości czyta się to jako subtelne odklejenie – tło sunące pół taktu za pierwszym planem, efekt „pływania", który natychmiast zdradza implementację w JS.

To nie jest nowe odkrycie. Dokładnie dlatego istnieje position: sticky. Przyklejone nagłówki były kiedyś JavaScriptem – listener scrolla przełączający position: fixed – i drgały właśnie z tego powodu, więc przeglądarka wchłonęła ten wzorzec do CSS, gdzie compositor mógł go obsłużyć bez wycieczki przez główny wątek. Ta sama historia tłumaczy, czemu listenery wheel i touch stały się w Chrome domyślnie pasywne już w 2017 roku: listener niepasywny mógł wywołać preventDefault(), więc przeglądarka musiała czekać na JavaScript, zanim w ogóle przewinęła, a strony sprawiały wrażenie przyklejonych do palca.

Scroll-driven animations to to samo wchłonięcie, zastosowane do całej kategorii. To zwykła animacja CSS z jedną podmianą: zamiast czasu postęp od 0% do 100% napędza pozycja kontenera przewijania. Przewiń w połowie, a animacja jest w połowie. Przewiń z powrotem, a odtworzy się wstecz. Ponieważ to deklaratywne połączenie dwóch wartości, a nie callback, przeglądarka może je wykonać na compositorze – i nigdy nie reaguje na pozycję, którą użytkownik już widział.

Pasek postępu w kilku linijkach

Pierwsza oś to scroll(), która śledzi pozycję kontenera przewijania – domyślnie najbliższego przewijanego przodka. Pasek postępu czytania to animacja scaleX od 0 do 1:

css

.progress {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(root block);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}

Trzy świadome decyzje. linear, bo każde inne łagodzenie rozjechałoby pasek z realną pozycją czytania – pasek nie jest dekoracją, tylko odczytem. Brak czasu trwania, bo przy osi scrolla czas trwania nie ma znaczenia; postęp bierze się z pozycji przewijania. I scroll(root block) zamiast gołego scroll(), czyli jawne wskazanie dokumentu jako przewijanego kontenera – domyślnie brany jest najbliższy przewijany przodek, a poleganie na tym domyślnym zachowaniu to źródło najczęstszego błędu „czemu moja animacja stoi", do którego zaraz dojdziemy.

Reveal on scroll: oś view()

Druga oś, view(), śledzi co innego: jak daleko konkretny element przebył przez okno przewijania. Postęp to 0%, gdy zaczyna wchodzić, i 100%, gdy całkiem wyszedł. To zastępuje wzorzec obserwator plus klasa w całości:

css

.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(32px); }
to { opacity: 1; transform: translateY(0); }
}

animation-range to własność, która czyni to użytecznym, a jej cztery nazwane zakresy warto zapamiętać, bo każdy odpowiada na inne pytanie:

  • entry – od pojawienia się pierwszego piksela elementu przy krawędzi okna przewijania do chwili, gdy jest w całości w środku. Zakres dla wszystkiego, co ma przybyć.
  • exit – od momentu, gdy element zaczyna wychodzić, do jego całkowitego zniknięcia. Do wygaszania rzeczy, które odchodzą.
  • cover – cała podróż elementu, od pierwszego piksela w środku po ostatni na zewnątrz. To wartość domyślna, dlatego animacja bez animation-range dochodzi do stanu końcowego dopiero, gdy element opuszcza ekran – czyli prawie nigdy nie tak, jak chciałeś.
  • contain – tylko odcinek, na którym element jest w całości widoczny w oknie przewijania. Przydatne do efektów, które mają działać, gdy użytkownik faktycznie na coś patrzy.

Czyli entry 0% entry 60% znaczy: odegraj całą animację w pierwszych 60% fazy wejścia, a potem trzymaj stan końcowy. Jeśli chcesz, żeby odsłonięcie zaczynało się nieco przed dotarciem elementu do krawędzi, view-timeline-inset pozwala zmniejszyć albo powiększyć okno przewijania, względem którego mierzony jest zakres – ujemny inset startuje animację wcześniej, co zwykle jest tym, co w praktyce oznacza „ma już być widoczne, zanim tam spojrzę".

Pułapka, która kosztuje godzinę: skrót animation resetuje oś

Ta jest naprawdę zaskakująca, zawodzi po cichu i łapie prawie każdego przynajmniej raz.

animation to skrót, a jak każdy skrót w CSS resetuje wszystkie swoje składowe do wartości początkowych – także te, których nie wpisałeś. animation-timeline i animation-range należą do tej rodziny. Więc poniższy kod wygląda zupełnie sensownie i nie robi absolutnie nic:

css

/* ZEPSUTE: skrót resetuje animation-timeline z powrotem do auto */
.reveal {
animation-timeline: view();
animation-range: entry 0% entry 60%;
animation: fade-up linear both;
}

Animacja się wykonuje – na domyślnej osi czasu, natychmiast, raz, przy wczytaniu strony. Element wskakuje na miejsce w chwili zastosowania arkusza stylów, a scroll nie ma z tym nic wspólnego. Naprawą jest wyłącznie kolejność:

css

/* Poprawnie: oś i zakres po skrócie */
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}

Te same własności, te same wartości, inna kolejność, zupełnie inne zachowanie. Warto zapisać sobie z tego regułę: w animacji sterowanej scrollem animation-timeline i animation-range są zawsze dwiema ostatnimi deklaracjami w bloku. Najboleśniej gryzie to wtedy, gdy skrót przychodzi skądinąd – z klasy narzędziowej, z design systemu, z nadpisania w @media – i po cichu rozbraja oś, którą ustawiłeś trzy reguły wcześniej.

Nazwane osie: animowanie jednej rzeczy na podstawie drugiej

scroll() i view() są anonimowe – zawsze odnoszą się do samego elementu albo jego przewijanego przodka. Gdy chcesz, żeby element był animowany scrollem innego elementu, nadajesz osi nazwę i odwołujesz się do niej gdzie indziej:

css

.gallery {
overflow-x: auto;
scroll-timeline: --gallery inline;
}
.gallery-indicator {
animation: fill linear both;
animation-timeline: --gallery;
timeline-scope: --gallery;
}

Oś --gallery żyje na poziomo przewijanej galerii, ale wskaźnik w innym miejscu DOM może za nią podążać. Nazwana oś jest domyślnie widoczna tylko dla potomków; timeline-scope podnosi nazwę na tyle wysoko w drzewie, żeby widziało ją rodzeństwo i dalsi krewni. To samo dotyczy view-timeline-name.

Progressive enhancement nie jest tu opcją

Wsparcie pojawiło się najpierw w Chromium, potem w Safari, a Firefox jest najwolniejszy – sprawdź Can I Use, zanim zdecydujesz, ile na tym oprzeć. To czyni poprawną strukturę bezdyskusyjną: treść w pełni widoczna i działająca domyślnie, animacja dołożona na wierzch za bramką @supports.

css

.reveal {
opacity: 1; /* domyślnie: widoczne wszędzie */
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
}

To zagnieżdżenie rozwiązuje dwa problemy naraz. Przeglądarki bez wsparcia dostają gotową treść – bez błysku ukrytego tekstu i pustej strony. A użytkownicy, którzy poprosili o ograniczenie ruchu, nie dostają go wcale, nawet w przeglądarkach, które mogłyby go odtworzyć. Zwróć uwagę, że klatka from { opacity: 0 } nadal istnieje; po prostu nigdy się nie stosuje, jeśli oba warunki nie są spełnione. To właśnie różnica między ulepszeniem a zależnością.

Pułapki i dobre praktyki

  • Animuj transform i opacity, nic więcej. Tylko one zostają na compositorze. Animowanie width, top czy box-shadow na osi scrolla przywraca layout i paint w każdej klatce przewijania – można uznać to za gorsze niż dawny JavaScript, bo teraz dzieje się przy każdym pikselu scrolla, a nie w dławionym callbacku.
  • Nigdy nie ukrywaj treści, którą może odsłonić tylko animacja. Jeśli opacity: 0 jest stanem bazowym, a tylko animacja go podnosi, to w przeglądarce bez wsparcia tekst jest niewidoczny na zawsze – i to niewidoczny w taki sposób, że nadal zajmuje miejsce w layoucie i nadal czyta go czytnik ekranu. Najgorsze z obu światów. Stanem bazowym musi być stan widoczny.
  • „Czemu moja animacja stoi?" to niemal zawsze kontener przewijania. scroll() czepia się najbliższego przodka, którego overflow nie jest visible. Wystarczy overflow: hidden na wrapperze z zupełnie innego powodu, a oś po cichu przypnie się do niego, on nigdy się nie przewija i postęp zostaje na 0% na zawsze. Używaj overflow: clip tam, gdzie chodzi tylko o niemalowanie poza pudełkiem – nie tworzy kontenera przewijania – albo wskaż przewijany kontener jawnie przez scroll(root block).
  • Używaj both jako fill mode. Bez tego element pokazuje styl bazowy aż do początku zakresu, a potem skacze do klatki from na granicy. Skok jest mały, widoczny i doprowadza do szału przy diagnozowaniu.
  • Ograniczenie ruchu to ustawienie medyczne, nie uprzejmość. Parallax i długie przemieszczenia powiązane ze scrollem to podręcznikowe wyzwalacze zaburzeń przedsionkowych. Zamknij ruch za prefers-reduced-motion: no-preference; krótkie wygaszenie przez opacity zwykle jest w porządku, translate o 200px już nie.
  • Nie kasuj JavaScriptu, którego nadal potrzebujesz. Jeśli reveal odpala też zdarzenie analityczne, doczytuje dane albo oznacza element jako przeczytany, to wciąż robota dla IntersectionObserver. Oś CSS zastępuje prezentację, nie zachowanie – a splątane były tylko dlatego, że akurat dzieliły wyzwalacz.

Podsumowanie

Artykuł o INP dowodził, że główny wątek to najrzadszy zasób na stronie, a najlepsze długie zadanie to takie, które w ogóle się nie wykonuje. Scroll-driven animations to ta sama myśl zastosowana do całej kategorii efektów: to, co wymagało listenera scrolla, obserwatora, przełączania klasy i biblioteki w rodzaju ScrollTrigger czy AOS, staje się jedną własnością CSS, wykonywaną tam, gdzie jest tania, i tam, gdzie nie może zostać w tyle za scrollem, który użytkownik już zobaczył. Razem z View Transitions z poprzedniego artykułu wyłania się wzorzec: opisz ruch deklaratywnie i oddaj rysowanie przeglądarce. Twoja zostaje część, której nie da się oddelegować: decyzja, co zasługuje na ruch, i pewność, że dla każdego, kto ruchu nie zobaczy albo go nie chce, strona jest kompletna już bez niego.