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 bezanimation-rangedochodzi 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
transformiopacity, nic więcej. Tylko one zostają na compositorze. Animowaniewidth,topczybox-shadowna 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: 0jest 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óregooverflownie jestvisible. Wystarczyoverflow: hiddenna 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żywajoverflow: cliptam, gdzie chodzi tylko o niemalowanie poza pudełkiem – nie tworzy kontenera przewijania – albo wskaż przewijany kontener jawnie przezscroll(root block). - Używaj
bothjako fill mode. Bez tego element pokazuje styl bazowy aż do początku zakresu, a potem skacze do klatkifromna 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,translateo 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.