Nem elemeket animálsz, hanem képeket: a View Transitions API belülről

A startViewTransition() egy sor, a demók gyönyörűek – csak közben a böngésző nem a DOM-odat animálja, hanem lefényképezi. Ebből következik a kattinthatatlan oldal, a duplikált nevek miatti néma skip és a négy másodperces timeout is.

Egy nézetváltási animáció SPA-ban sokáig úgy készült, hogy az ember hazudott a felhasználónak. A régi DOM-ot életben tartotta, abszolút pozícióba tette, getBoundingClientRect()-tel kimérte, hova kell csúsznia, FLIP-technikával visszatranszformálta, majd imádkozott, hogy közben senki ne resizolja az ablakot. Aki írt már ilyet, az pontosan tudja, miért lett a Floating UI-ból és társaiból iparág.

A View Transitions API-val ez papíron egy sor: document.startViewTransition(callback), és a böngésző szépen áttűnteti a régi állapotot az újba. A demók látványosak, a lelkesedés érthető. Amit viszont a demók nem mondanak el: a böngésző nem a DOM-odat animálja, hanem lefényképezi. És ebből az egyetlen mondatból következik szinte az összes meglepetés, amibe éles használatnál belefutsz — a kattinthatatlan oldaltól a némán kihagyott animációig.

Diagram a View Transitions API működéséről: bal oldalon a régi oldalállapot statikus, megfagyott snapshotja, jobb oldalon az új állapot élő nézete, köztük a nevesített elem áttűnő morph-animációjának útja

Screenshot-színház

A startViewTransition() hívásakor a következő történik: a böngésző képet készít az oldal aktuális állapotáról, felfüggeszti a renderelést, lefuttatja a callbackedet (ebben cseréled a DOM-ot), majd képet készít az új állapotról is. Ezután a két állapotot egy pszeudo-elem fába teszi, amit a valódi tartalom fölé renderel:

::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)   ← statikus kép a régi állapotról
      └─ ::view-transition-new(root)   ← élő nézet az új állapotról

A kettő nem szimmetrikus, és ez fontos. A ::view-transition-old tényleg egy pillanatfelvétel: ha az eltűnő nézetben videó ment, az a snapshoton megfagy. A ::view-transition-new viszont a specifikáció szavaival „élő reprezentáció”: ha az új tartalomban pörög egy spinner, az az áttűnés alatt is pörög. Az alapértelmezett animáció egy crossfade a kettő között, és ha egy elemnek saját view-transition-name-et adsz, az kiemelkedik a saját ::view-transition-group-jába, a böngésző pedig a pozícióját és a méretét is interpolálja a két állapot között. Ezek egyébként teljesen hétköznapi CSS-animációk: a DevToolsban megnézheted, lassíthatod, felülírhatod őket.

const vt = document.startViewTransition(() => {
  render(nextState); // csak DOM-csere, semmi más
});

vt.ready.catch(() => {
  // az animáció elmaradt – a DOM attól még frissült
});

Eddig a szép rész. Most jöjjön az, amiért ez a cikk készült.

Az oldal, amire nem lehet kattintani

Amíg az átmenet fut, a felhasználó nem az oldaladat látja, hanem a fölé rakott pszeudo-elemeket. A valódi, nevesített elemek addig nem renderelődnek — és ami ennél rosszabb: a ::view-transition overlay a teljes viewportot lefedi, és elkapja az összes kattintást. Egy 200 ms-os crossfade-nél ezt senki nem veszi észre. Egy 1,5 másodperces, gondosan koreografált hero-animációnál viszont a felhasználó rákattint a menüre, és nem történik semmi.

A javítás egyetlen sor, amit Bramus Van Damme snippetje óta fél internet másol:

::view-transition {
  pointer-events: none;
}

Hogy ez miért nem alapértelmezés, arról a CSS munkacsoportban is folyt vita. A pszeudo-elemekre eseménykezelőt kötni amúgy sem tudsz, a kattintás így a mögöttes — már az új állapotot mutató — DOM-ra esik, ami az esetek túlnyomó részében pontosan az, amit akarsz.

Két elem, egy név: néma skip

A view-transition-name-nek a snapshot pillanatában egyedinek kell lennie az oldalon. Ha két renderelt elem ugyanazt a nevet viseli — klasszikus eset: egy listakomponens, ahol mindenki view-transition-name: card-ot kapott —, a böngésző nem próbál okoskodni, hanem kihagyja a teljes átmenetet, és a ready promise InvalidStateError-rel utasítódik el. A DOM frissül, animáció nincs, hibaüzenet legfeljebb a konzolon, unhandled rejection formájában — vagyis az a fajta hiba, amit staging környezetben két hétig nem vesz észre senki, mert „csak” az animáció tűnt el.

Listáknál ezért a nevet az adatból kell képezni — style="view-transition-name: vt-item-42" —, ami pont olyan kényelmetlen, mint ahogy hangzik. A Level 2 specifikáció erre hozza az auto és a match-element kulcsszavakat, hogy a böngésző generáljon egyedi neveket, de a támogatásuk még mozgó célpont — ellenőrizd, mielőtt ráépítesz.

Fagyott renderelés, négy másodperces türelem

A callback futása alatt a renderelés áll. Nem lassú: áll. Async callbacknél a böngésző megvárja a visszaadott promise-t is — közben a felhasználó egy befagyott oldalt bámul. A Chrome dokumentációja ezért mondja ki: a callbackben ne csinálj semmit, csak DOM-cserét. Ha mégis megpróbálsz benne fetch-elni, a Chrome nagyjából négy másodperc után megunja, TimeoutError-rel kihagyja az átmenetet, és mehet a hibakeresés. Az adat legyen meg, mielőtt a startViewTransition()-t meghívod — az átmenet a már kész állapotok közti vizuális híd, nem loading-screen.

Ugyanez oldalak között

A mechanizmus dokumentumok közötti navigációra is működik: a régi oldal snapshotja túléli a navigációt, és az új oldalon úszik át. Nem kell hozzá JavaScript, csak egy kölcsönös opt-in mindkét érintett oldal CSS-ében:

@view-transition {
  navigation: auto;
}

Ez csak same-origin navigációra megy, finomhangolni a pageswap és pagereveal eseményekben lehet, és a négy másodperces szabály itt is él: ha a navigáció ennyi alatt nem ér célba, az átmenet elmarad. Lassú szerverrel tehát nem látványos áttűnést kapsz, hanem semmit — hacsak nem kombinálod prerenderrel, amivel viszont kifejezetten jól működik együtt.

A támogatás itt válik ketté. A same-document átmenet ma már mindenhol megvan: Chrome 111, Safari 18, és a Firefox 144 óta a sor teljes. A cross-document viszont Chrome 126-tól és Safari 18.2-től él, Firefoxban pedig még nyitott ticket.

Használnám? Igen — de nem úgy, ahogy a demók

Az API-nak van egy ritka erénye: természeténél fogva progressive enhancement. Ha a startViewTransition nem létezik, a fallback nem egy elcsúszott layout, hanem az, ami eddig is volt: animáció nélküli állapotváltás. Egy if (!document.startViewTransition) { render(nextState); return; } ág, és kész. Emiatt same-document átmenetre production-ben ma már nyugodt szívvel mondok igent: lista-átrendezés, téma-váltás, egy kép „kinagyítása” részletnézetbe — pár sor CSS, és olyan hatást kelt, amiért korábban egy animációs library-t kellett behúzni.

Amire nemet mondanék: a minden route-váltásra rákényszerített, többszereplős koreográfia. A pszeudo-elem fa debugolása türelemjáték, a duplikált nevek futásidőben, némán ütnek vissza, és minden egyes nevesített elem karbantartandó csatolást jelent két, egymásról nem tudó nézet között. A prefers-reduced-motion tisztelete pedig nem opció, hanem alapkövetelmény — a crossfade-et is kapcsold ki, ha a felhasználó azt kérte.

A lényeg, amit érdemes megjegyezni: a View Transitions API nem a DOM-odat animálja, hanem két képet — egy megfagyott múltat és egy élő jelent. Aki ezt fejben tartja, annak a gotchák nagy része nem meglepetés, hanem logikus következmény.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük