next.jsanimacjecss i układy

View Transitions w Next.js: animacje przejść między stronami bez biblioteki animacji

View Transitions w Next.js: animacje przejść między stronami bez biblioteki animacji

W poprzednim artykule pokazałem, że INP mierzy odstęp między kliknięciem a klatką, która to kliknięcie odzwierciedla, i że jedno długie zadanie na głównym wątku potrafi ten odstęp zrujnować, choćby sama animacja była tania. Przejścia między stronami to skrajna wersja tego problemu, bo każą głównemu wątkowi wykonać najdroższą pracę, jaką ma – wyrenderować całą nową stronę – i jednocześnie puścić płynną animację w 60 klatkach na sekundę.

Warto powiedzieć precyzyjnie, na czym ta praca polegała, bo jej nazwa to trzydzieści sekund historii, które tłumaczą całe API, jakie ją zastąpiło.

Co naprawdę robiły biblioteki: FLIP

W 2015 roku Paul Lewis opisał technikę, którą nazwał FLIP – First, Last, Invert, Play. Nie da się tanio przeanimować elementu z jednej pozycji w layoucie do drugiej, bo top i left wymuszają przeliczenie layoutu w każdej klatce. Więc oszukujesz. Zapisujesz pozycję First, pozwalasz DOM-owi skoczyć natychmiast do stanu Last, potem Invert – odwracasz ten skok transformem tak, żeby element wyglądał, jakby się nie ruszył – i na koniec Play, czyli animujesz transform z powrotem do zera. Użytkownik widzi płynne przesunięcie. Przeglądarka animowała wyłącznie transform.

Każda animacja współdzielonego layoutu, jaką widziałeś – layoutId z Framer Motion, plugin Flip w GSAP, każde „magic move" w narzędziu do projektowania – to FLIP. I FLIP ma koszt wpisany w konstrukcję: coś musi utrzymać przy życiu jednocześnie stary i nowy element, wywołać na obu getBoundingClientRect() i zrobić to synchronicznie, w JavaScripcie, w środku zmiany trasy. W aplikacji na App Routerze oznacza to trzymanie wychodzącej strony zamontowanej, gdy nadchodząca się renderuje, koordynowanie animacji wyjścia przez AnimatePresence i dopiero potem odmontowanie starego drzewa.

View Transitions API odbiera ci tę sztuczkę i oddaje ją przeglądarce. Zamiast trzymać dwa żywe drzewa DOM i je mierzyć, przeglądarka fotografuje stary stan, pozwala DOM-owi zaktualizować się dowolnie, fotografuje nowy stan i animuje między dwoma obrazami – na compositorze, dokładnie tak jak transform i opacity z artykułu o mikrointerakcjach.

Sekwencja i drzewo, które buduje

Sednem API jest jedno wywołanie. Opakuj aktualizację DOM w document.startViewTransition(), a przeglądarka wykona stałą sekwencję: zamrozi renderowanie, zrobi zrzut bieżącej strony, uruchomi twój callback, zrobi zrzut wyniku i zbuduje nad dokumentem drzewo pseudoelementów.

javascript

function navigate(update) {
if (!document.startViewTransition) {
update(); // brak wsparcia: po prostu aktualizuj, bez animacji
return;
}
document.startViewTransition(() => update());
}

To drzewo warto znać z nazwy, bo to różnica między świadomym dostosowaniem przejścia a zgadywaniem selektorów:

css

::view-transition /* nakładka przykrywająca całą stronę */
::view-transition-group(nazwa) /* animuje rozmiar i pozycję */
::view-transition-image-pair(nazwa)
::view-transition-old(nazwa) /* zrzut „przed” */
::view-transition-new(nazwa) /* zrzut „po” */

Ten podział ma znaczenie. To group się przesuwa i skaluje – więc animation-duration należy właśnie tam. To para old/new robi przenikanie – więc opacity i filtry należą tam. Ustawienie czasu trwania na ::view-transition-new i zdziwienie, czemu element mimo wszystko przeskakuje na miejsce, to jeden z najczęstszych pierwszych błędów z tym API.

To, co zaskakuje wszystkich: zrzut to fotografia, nie element

To najważniejszy model mentalny i jednocześnie rzecz, którą prostota tego API skrzętnie ukrywa. ::view-transition-old to nie twój stary element wyrenderowany w mniejszym rozmiarze. To jego statyczny obraz, uchwycony raz, w jednej chwili.

Wszystko inne z tego wynika. <video> wewnątrz przejścia zamarza na jednej klatce na cały czas jego trwania. Spinner przestaje się kręcić. Tekstu w zrzucie nie da się zaznaczyć i nie sięgnie go czytnik ekranu – prawdziwy DOM pod spodem został już podmieniony, a to, na co patrzysz, jest zdjęciem czegoś, co już nie istnieje. GIF się zatrzymuje. Animacja CSS wewnątrz uchwyconego elementu staje w pół drogi.

Przy 200 ms nic z tego nie ma znaczenia. Przy 1000 ms wszystko staje się widoczne i lekko zepsute. To prawdziwy powód, żeby trzymać przejścia krótko, i jest on znacznie lepszy niż „długie animacje sprawiają wrażenie wolnych" – przy pewnym czasie trwania użytkownik przestaje widzieć przejście, a zaczyna widzieć zamrożony zrzut ekranu twojej aplikacji.

To samo tłumaczy pułapkę proporcji. Jeśli stary element to miniatura 1:1, a nowy to hero 16:9, przeglądarka interpoluje między dwoma obrazami o różnych kształtach i w locie widać wyraźne ściśnięcie. Lekarstwem jest nadanie obu elementom tych samych proporcji albo nadpisanie object-fit na pseudoelementach old/new, żeby zrzuty były kadrowane, a nie rozciągane.

Nazywanie: cały kontrakt

Domyślnie dostajesz przenikanie całej strony. Ciekawie robi się, gdy nadasz elementowi view-transition-name. Nazwany element zostaje wyjęty ze zrzutu strony i dostaje własną grupę, którą przeglądarka interpoluje po pozycji i rozmiarze, a nie tylko po przezroczystości. Ta sama nazwa po obu stronach oznacza, że przeglądarka przesuwa i skaluje jedno w drugie. To FLIP, zrobiony za ciebie, w CSS.

css

.card-image {
view-transition-name: hero;
}
::view-transition-group(hero) {
animation-duration: 400ms;
animation-timing-function: ease-in-out;
}

Jedna twarda zasada: w danym momencie view-transition-name musi być unikalne w dokumencie. Dwa elementy z tą samą nazwą powodują, że całe przejście się nie wykonuje – nie tylko ten element, ale wszystko – i dzieje się to po cichu, a strona po prostu przeskakuje, jak zawsze. Przy liście kart to domyślny wynik, bo każda karta chce tej samej nazwy. Rozwiązaniem jest wyprowadzenie nazwy z id rekordu, czyli dokładnie to, co robi poniższe API Reacta.

React 19.2 i Next.js 16: jeden komponent

Robienie tego ręcznie we frameworku zawsze było niewygodne, bo moment aktualizacji DOM należy do Reacta, nie do ciebie. React rozwiązuje to komponentem <ViewTransition>, a Next.js używa go od wersji 16 bez żadnej konfiguracji – App Router działa na buildach React canary, które już go zawierają, a nawigacje między trasami są Transitions Reacta, więc animacje uruchamiają się przy nawigacji automatycznie.

tsx

import { ViewTransition } from "react";
import Image from "next/image";
import Link from "next/link";
export function ProjectCard({ project }) {
return (
<Link href={"/projects/" + project.slug}>
<ViewTransition name={"project-" + project.slug}>
<Image src={project.cover} alt={project.title} />
</ViewTransition>
</Link>
);
}

Na stronie szczegółów owijasz duży obraz w <ViewTransition> z tą samą nazwą, a React sam paruje oba i morfuje miniaturę w hero. Powrót odwraca animację. Zwróć uwagę na to, czego tu nie ma: żadnych animacji wyjścia, AnimatePresence, mierzenia, identyfikatorów layoutu. Nazwa jest całym kontraktem.

Jeden szczegół z dokumentacji Next.js wart powtórzenia, bo zawodzi po cichu: share i default="none" muszą występować razem. default="none" powstrzymuje nazwany element przed przenikaniem przy każdym niezwiązanym przejściu na stronie – ale z default="none" i bez propa share para cicho przestaje się morfować w ogóle.

Jest też warunek czasowy, który łatwo wziąć za błąd. Morf zagra tylko wtedy, gdy strona docelowa wyrenderuje się w tym samym commicie co nawigacja, co zachodzi dla stron prefetchowanych. Jeśli cel najpierw zawiesi się na fallbacku ładowania, para się nie utworzy i treść odegra zamiast tego swoją animację wejścia, gdy w końcu dotrze.

Kierunek: powiedz użytkownikowi, w którą stronę poszedł

Morf mówi użytkownikowi, co się przesunęło. Kierunek mówi, gdzie jest – głębiej czy z powrotem. Next.js 16.2 dodał do <Link> (oraz do router.push()) prop transitionTypes, który oznacza nawigację, dzięki czemu <ViewTransition> może przypisać każdemu oznaczeniu inną animację.

tsx

<Link href="/projects/runvalis" transitionTypes={["nav-forward"]}>
Runvalis
</Link>
<ViewTransition
enter={{ "nav-forward": "slide-in", "nav-back": "slide-out", default: "none" }}
exit={{ "nav-forward": "slide-out", "nav-back": "slide-in", default: "none" }}
default="none"
>
<main>{children}</main>
</ViewTransition>

Dwie rzeczy łatwo tu zepsuć. Po pierwsze, wrapper musi być w każdym page.tsx, nie w layoucie – layouty przetrwają nawigację, więc ich zawartość nigdy nie wchodzi ani nie wychodzi i animacja po prostu się nie odpala. Po drugie, przycisk „wstecz" przeglądarki i gesty przesunięcia nie niosą typu przejścia, więc slajd kierunkowy się dla nich nie zagra – zadziała tylko morf współdzielonego elementu. Ta asymetria jest celowa, ale oznacza, że przycisk „wstecz" trzeba przetestować osobno od własnych linków powrotnych.

Wersja zupełnie bez frameworka

Warto wiedzieć, nawet jeśli nigdy nie opuszczasz Next.js: ten sam mechanizm istnieje dla zwykłych stron wielodokumentowych, całkiem bez JavaScriptu. Dwa dokumenty z tego samego origin, które się na to zgodzą, dostają przejście przy zwykłej nawigacji:

css

@view-transition {
navigation: auto;
}

To cała konfiguracja. view-transition-name działa wtedy między dokumentami – miniatura na jednej stronie HTML morfuje się w hero na drugiej. Trafiło to najpierw do Chromium i jest najmocniejszym argumentem, że to API nie jest funkcją Reacta, która przypadkiem używa CSS, tylko funkcją przeglądarki, którą React przypadkiem ładnie wystawia.

Pułapki i dobre praktyki

  • Zawsze traktuj to jako progressive enhancement. Tam, gdzie przeglądarka nie ma wsparcia, nic się nie psuje – nawigacja dzieje się natychmiast, tak jak zawsze. To poprawny sposób porażki. Nie sięgaj po polyfill, żeby wymusić animację wszędzie; wprowadziłbyś z powrotem dokładnie ten koszt głównego wątku, dla którego usunięcia to API powstało.
  • Debuguj w zwolnionym tempie. Drzewo pseudoelementów istnieje wyłącznie w trakcie przejścia, co przy 300 ms czyni je praktycznie niemożliwym do obejrzenia. Panel Animations w Chrome DevTools pozwala zejść z prędkością odtwarzania do 25% albo 10% – przy takim tempie faktycznie widać, która grupa się przesuwa, można złapać zrzut, który się ściska, i wyłapać element, który przenika, choć miał się morfować.
  • Szanuj prefers-reduced-motion. Slajdy kierunkowe symulują ruch przez cały viewport, co jest podręcznikowym wyzwalaczem dolegliwości przedsionkowych. Minimum to wyzerowanie animation-duration i animation-delay dla ::view-transition-old(*), ::view-transition-new(*) i ::view-transition-group(*) w zapytaniu reduce – treść podmieni się wtedy natychmiast, czyli po prostu tak, jak domyślnie robi to przeglądarka. Zwróć uwagę, że to nie to samo co usunięcie informacji zwrotnej: nawigacja nadal się dzieje, tylko nie podróżuje.
  • Nakładka domyślnie zjada kliknięcia. Gdy przejście trwa, ::view-transition leży nad stroną i przechwytuje zdarzenia wskaźnika, więc wszystko kliknięte w trakcie animacji przepada. Ustaw ::view-transition { pointer-events: none }, żeby przepuścić je do żywej strony. Hit-testing i tak pomija nazwanych uczestników na czas trwania, więc nie nazywaj elementów, w które użytkownik klika seriami.
  • Zrzuty kosztują pamięć. Każdy nazwany element staje się osobną teksturą, a sfotografowanie bardzo długiej strony nie jest darmowe. Nazywaj to, co niesie znaczenie, a nie wszystko, co się rusza.
  • Zakotwicz nagłówek. Jeśli przesuwa się cały viewport, użytkownik traci stały punkt odniesienia. Nadaj nagłówkowi własną nazwę, ustaw animation: none na jego grupie i display: none na starym zrzucie, żeby uniknąć błysku dwóch nagłówków – wtedy rusza się tylko treść.
  • View transition jest niewidzialne dla technologii wspomagających. Nie przenosi fokusu i niczego nie ogłasza. Wszystko z artykułu o dostępnych komponentach nadal obowiązuje: po nawigacji zarządzanie fokusem i komunikaty to twoja robota. Animacja jest dekoracją na nawigacji, która musi już działać bez niej.

Podsumowanie

Artykuł o INP kończył się myślą, że postrzegana wydajność jest tak mocna, jak jej najsłabsze ogniwo, i że tym ogniwem zwykle jest własny JavaScript. View Transitions to ten sam argument zastosowany do nawigacji: zamiast wykonywać FLIP ręcznie – dwa zamontowane drzewa, synchroniczne pomiary, skoordynowane animacje wyjścia, wszystko na głównym wątku w trakcie zmiany trasy – deklarujesz, co łączy dwa stany, i pozwalasz przeglądarce narysować to połączenie z dwóch fotografii. Ceną jest mały słownik (name, share, enter, exit, transitionTypes), jedna twarda zasada o unikalnych nazwach i pamiętanie, że to, na co użytkownik patrzy w trakcie przejścia, jest obrazem, a nie twoją aplikacją. Wobec kosztu utrzymywania biblioteki animacji zsynchronizowanej z routerem to wyjątkowo dobry układ.