Animowanie napięcia za pomocą ViewTransition: alternatywy, obrazy, czcionki
Dowiedz się, jak otoczenie elementu Suspense w ViewTransition animuje zamianę szkieletu na treść, jak właściwości zapobiegają zbędnym animacjom oraz jak React czeka na załadowanie obrazów i czcionek.
Źródła: React 19.3 View Transitions with Suspense: Animate the Fallback, Wait for Images and Fonts
Granica Suspense już sama w sobie opisuje przejście: pokazuje rozwiązanie awaryjne, a następnie wymienia treść. Historycznie to wymiany dokonywały się poprzez nagłe cięcie, po którym chwilę później pojawiała się grafika, a potem ładowały się czcionki webowe. React 19.3 umożliwia umieszczenie tej granicy wewnątrz <ViewTransition>, dzięki czemu wymiana staje się animacją, a co nie jest tak oczywiste, React opóźnia tę animację do chwili, gdy zdjęcia i czcionki webowe w nowej treści zostaną załadowane. Ten przewodnik pokazuje, jak to skonfigurować, jak zapobiec uruchamianiu animacji w niewłaściwym momencie, gdzie umieszczać granice oraz co faktycznie robią wbudowane mechanizmy oczekiwania.
Ponieważ to zachowanie zostało wprowadzone niedawno, w momencie pisania tego tekstu należy sprawdzić szczegóły w aktualnej dokumentacji React, zanim będzie się polegać na dokładnych czasach wykonywania operacji w środowisku produkcyjnym. Przykłady zakładają, że już wiesz, iż <ViewTransition> animuje na podstawie określonych sygnałów (enter, exit, update) i że musi otaczać elementy DOM, które animuje.
Zmiana efektu pokazywania na animację
Najprostsza wersja polega po prostu na umieszczeniu <ViewTransition> wokół obszaru Suspense:
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
Do wymiany nie potrzebujesz metody startTransition, ponieważ React już traktuje ujawnienie granicy Suspense jako przejście. Gdy Forecast zakończy proces zawieszania, operacja zastępowania szkieletu odbywa się w ramach przejścia między widokami. Ponieważ szkielet i prognoza znajdują się wewnątrz tego samego elementu <ViewTransition>, dzielą się tym samym nazwą zrobionego zdjęcia stanu, więc zmiana jest traktowana jako update, a przeglądarka stopniowo przechodzi od jednego do drugiego widoku.
Przykład działającej prognozy
Demo rozpoczyna się od niezbędnych importów:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
Kod komponentu przechowuje w pamięci cache jedną instancję Promise na każde miasto w strukturze Map, ponieważ funkcja use() musi otrzymywać tę samą instancję Promise przy każdym renderowaniu; tworzenie nowej instancji przy każdym renderowaniu spowodowałoby nieskończone oczekiwanie. Funkcja Forecast odczytuje już rozwiązane dane za pomocą use(), Skeleton wyświetla karcę z elementem tymczasowym, a App steruje całym procesem wewnątrz funkcji startTransition:
// use() needs the same Promise instance across renders, so cache it
const cache = new Map();
function fetchForecast(city) {
if (!cache.has(city)) {
cache.set(city, new Promise(resolve =>
setTimeout(() => resolve({ city, high: 31, low: 24, sky: '⛅' }), 1100)
));
}
return cache.get(city);
}function Forecast({ city }) {
const f = use(fetchForecast(city));
return <div className="card">{f.sky} {f.city}: {f.high}° / {f.low}°</div>;
}function Skeleton() {
return <div className="card skeleton">Loading forecast…</div>;
}export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => startTransition(() => setShow(s => !s))}>
{show ? 'Hide' : 'Load'} forecast
</button>
{show && (
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Forecast city="Pune" />
</Suspense>
</ViewTransition>
)}
</>
);
}
Kilka reguł CSS formatuje kartę oraz nadaje szkieletowi neutralny tło, wykorzystując funkcję light-dark() dla obu schematów kolorystycznych:
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
Ostatnia reguła spowalnia standardowe przejście między stanami, dzięki czemu zmiany są dobrze widoczne podczas eksperymentowania:
/* slow the default cross-fade so the swap is visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Kliknięcie przycisku pokazuje szkielet, czeka około sekundy na symulowane pobieranie danych, a następnie stopniowo pokazuje prognozę na tle szkieletu. Jeśli jednak ukryjesz go i ponownie załadujesz, zauważysz problem: dane są teraz w pamięci podręcznej, więc nie pojawia się żaden plan awaryjny, mimo to karta i tak się pokazuje, tak samo jak zniknęła podczas ukrycia. To prowadzi do następnej sekcji.
Zatrzymywanie animacji, których nie żądaliśmy
<ViewTransition> bez żadnych atrybutów animuje każdy moment aktywacji: enter przy montowaniu, exit przy demontowaniu oraz update za każdym razem, gdy zmienia się zawartość wewnątrz. Gdy jest to używane z Suspense, powstają nawet trzy animacje, podczas gdy zamierzano tylko jedną. Dokumentacja Reacta opisuje pożądane zachowanie podczas ładowania interfejsu użytkownika:
- Alternatywa powinna pojawić się natychmiast i bez animacji, ponieważ użytkownik właśnie podjął działanie i oczekuje natychmiastowej odpowiedzi.
- Zmiana z alternatywy na treść powinna być animowana.
- Treść, która jest już zbuforowana i dlatego nigdy nie zostaje wstrzymana, powinna pojawić się natychmiast i bez animacji.
Dwie te właściwości dokładnie to opisują. default="none" wyłącza wszystkie mechanizmy aktywacji, a update="auto" włącza ten, który nas interesuje:
<ViewTransition update="auto" default="none">
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
Porównanie obu konfiguracji
Druga demonstracja używa tych samych importów:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
Umożliwia ono załadowanie jednego z trzech miast oraz przełączanie się pomiędzy prostą wersją <ViewTransition> a wersją z dwoma parametrami. Zwróć uwagę na key={city} w elementie <ViewTransition>: przy zmianie miasta tworzony jest nowy obszar, zamiast aktualizować istniejący.
const cache = new Map();
function fetchForecast(city) {
if (!cache.has(city)) {
const data = { Pune: '⛅ 31° / 24°', Goa: '🌧 29° / 26°', Shimla: '☀️ 18° / 9°' }[city];
cache.set(city, new Promise(r => setTimeout(() => r(data), 1100)));
}
return cache.get(city);
}function Forecast({ city }) {
const text = use(fetchForecast(city));
return <div className="card">{city}: {text}</div>;
}export default function App() {
const [city, setCity] = useState(null);
const [onlyUpdates, setOnlyUpdates] = useState(false);
const props = onlyUpdates ? { update: 'auto', default: 'none' } : {}; return (
<>
<div className="bar">
{['Pune', 'Goa', 'Shimla'].map(c => (
<button key={c} onClick={() => startTransition(() => setCity(city === c ? null : c))}>
{city === c ? 'Hide' : 'Load'} {c}
</button>
))}
<label><input type="checkbox" checked={onlyUpdates} onChange={e => setOnlyUpdates(e.target.checked)} /> only animate updates</label>
</div>
{city && (
<ViewTransition key={city} {...props}>
<Suspense fallback={<div className="card skeleton">Loading {city}…</div>}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
)}
</>
);
}
Skrypt stylów dodaje elastyczną paskę narzędzi oraz tę samą kartę, szkielet i reguły spowalniania efektu przejścia:
.bar { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Gdy jest włączone „tylko animowane aktualizacje”, miasto, które jeszcze nie zostało załadowane, natychmiast pokazuje swój szkielet, a następnie animuje się do pełnego wyglądu, podczas gdy wcześniej załadowane miasto po prostu się pojawia, bez animacji. Dokumentacja zaleca używanie animacji przy pokazywaniu się elementów typu Suspense tylko sporadycznie, a w ogóle nie dla treści z pamięci cache, które mogłyby zostać natychmiast wyświetlone, ponieważ w takim przypadku animacja jedynie dodaje opóźnienie. W praktyce update="auto" default="none" powinno być używane niemal we wszystkich elementach <ViewTransition> otaczających obszar Suspense.
Lokalizacja decyduje, które mechanizmy zostaną uruchomione
Miejsce umieszczenia elementu <ViewTransition> w stosunku do obszaru Suspense określa, które mechanizmy zostaną uruchomione, a więc które części można animować oddzielnie.
Poza obszarem: jedna aktualizacja
Zamknięcie elementu suspense z zewnątrz tworzy jedną granicę, która postrzega zmianę jako update:
<ViewTransition update="cross">
<Suspense fallback={<A />}>
<B />
</Suspense>
</ViewTransition>
Fallback i treść dzielą się tym samym nazwą snapshota, więc jedyne, co można zrobić, to przejść pomiędzy nimi poprzez efekt cross-fade lub morph, tutaj za pomocą specjalnej klasy cross.
W obu gałęziach: exit plus enter
Rozdzielné zamknięcie fallbacku i treści tworzy dwie niezależne granice:
<Suspense
fallback={<ViewTransition exit="out"><A /></ViewTransition>}
>
<ViewTransition enter="in"><B /></ViewTransition>
</Suspense>
Fallback opuszcza się przy użyciu animacji exit, a treść pojawia się przy użyciu animacji enter, co umożliwia, by element tymczasowy opadał w dół, podczas gdy prawdziwa treść unosiła się na swoje miejsce.
Karta profilu z użyciem rozmieszczenia wewnątrz
Trzecia demonstracja ponownie zaczyna się od importów:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
loadProfile tworzy nową obietnicę (Promise) przy każdym kliknięciu, więc mechanizm Suspense zawiesza się za każdym razem. Obie części – Profile oraz Skeleton – otaczają swój kod w osobny element <ViewTransition>, używając odpowiednio wartości enter="rise" i exit="sink" przy ustawieniu domyślnym default="none". Wartość key={attempt} w elementach Suspense ma znaczenie: powoduje ona utworzenie nowego mechanizmu zawieszania przy każdym załadowaniu, ponieważ już istniejący mechanizm, który ponownie się zawiesza w ramach przejścia, nadal wyświetla poprzednią treść zamiast treści awaryjnej.
function loadProfile() {
// a fresh Promise per click, so it suspends every time
return new Promise(r => setTimeout(() => r({ name: 'Rahul', role: 'Frontend' }), 1100));
}function Profile({ promise }) {
const p = use(promise);
return (
<ViewTransition enter="rise" default="none">
<div className="card">👤 {p.name} — {p.role}</div>
</ViewTransition>
);
}function Skeleton() {
return (
<ViewTransition exit="sink" default="none">
<div className="card skeleton">loading…</div>
</ViewTransition>
);
}export default function App() {
const [attempt, setAttempt] = useState(0);
const [promise, setPromise] = useState(null);
return (
<>
<button onClick={() => startTransition(() => { setAttempt(a => a + 1); setPromise(loadProfile()); })}>
{attempt ? 'Load again' : 'Load profile'}
</button>
{promise && (
// key: a NEW boundary each time. A mounted boundary that re-suspends
// inside a Transition keeps showing the old content instead of the fallback.
<Suspense key={attempt} fallback={<Skeleton />}>
<Profile promise={promise} />
</Suspense>
)}
</>
);
}
Podstawowe style nadają karcie i elementowi skeleton swojego wyglądu:
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: light-dark(#64748b, #94a3b8); background: light-dark(#e2e8f0, #2a2f3a); }
Zasady animacji odnoszą się do określonych klas. ::view-transition-old(.sink) ma zastosowanie do obrazu szkieletu wychodzącego, a ::view-transition-new(.rise) do treści przychodzącej, przy czym każda z nich ma własne klatki kluczowe:
::view-transition-old(.sink) { animation: 240ms ease-in both sink; }
::view-transition-new(.rise) { animation: 320ms ease-out both rise; }
@keyframes sink { to { transform: translateY(16px); opacity: 0; } }
@keyframes rise { from { transform: translateY(-16px); opacity: 0; } }
Czekanie na obrazy, czcionki i pliki ze stylami
Ograniczenia typu Suspense od dawna hamują przetwarzanie danych oraz kodu ładowanego w tle. W React 19.3 zbiór elementów, które są wstrzymywane przez takie ograniczenia, powiększa się, gdy animację pokazywania treści realizuje element <ViewTransition>:
- Dane odczytywane za pomocą
use()oraz komponentów ładowanych w tle: oczekiwane w obu przypadkach. - Pliki ze stylami zadeklarowane za pomocą
<link rel="stylesheet" precedence="…">: oczekiwane w obu przypadkach (z czasem wygaśnięcia dla zwykłego Suspense).
<img>: ignorowane przez zwykłe Suspense; podczas aktualizacji przy przejściu między widokami React czeka na dekodowanie do 800 ms, z wyjątkiem obrazów, które używają loading="lazy" lub mają obsługę onLoad.Uzasadnienie jest wizualne – animacja do karty, której zdjęcie jest tylko częściowo wyświetlone lub której tekst nadal używa czcionki systemowej, wygląda gorzej niż zwykłe przerwanie widoku. Dlatego React odkłada pokazanie elementów po przejściu między widokami, dopóki zdjęcia i nowe czcionki w tym obszarze nie będą dostępne. Bez przejścia między widokami nic się nie zmienia, a obrazy pojawiają się w momencie ich dostarczenia.
Ile czasu React jest skłonne czekać
Czekanie ma określone granice. Te szczegóły pochodzą z analizy implementacji react-dom, a nie z dokumentacji, więc należy je traktować jako detalizacje implementacji, które mogą ulec zmianie:
- Dla obrazów React wywołuje metodę
img.decode()i opóźnia zapisanie o co najwyżej 800 ms. - Ponadto szacuje, jak duży jest każdy obraz na podstawie atrybutów
widthiheight, oraz jak szybka jest sieć na podstawie danych z Resource Timing. Gdy szacunek wskazuje, że pobieranie zajmie ponad około 500 ms, React przydziela im zaledwie 50 ms, a następnie i tak przeprowadza operację. - Bez atrybutów
widthiheightszacunek zakłada obraz o rozmiarach 100×100, więc rzeczywiste wymiary sprawiają, że szacunek jest trafny. - Dla czcionek limit wynosi około 500 ms.
Demonstrowanie oczekiwania na obraz
Demo z obrazem importuje kilka dodatkowych hooków:
import { ViewTransition, Suspense, use, useState, useRef, useLayoutEffect, startTransition } from 'react';
Każde kliknięcie pobiera wpis z losowo wybranym adresem URL zdjęcia, dzięki czemu obraz nigdy nie jest cacheowany. Dwa pola wyświetlają tę samą kartkę obok siebie. To po lewej stronie pozwala Reactowi czekać na zdjęcie; to po prawej dodaje pusty obsługiwacz onLoad, który wyklucza obraz, dzięki czemu kartka pokazuje się natychmiast, a zdjęcie przychodzi później. Funkcja useLayoutEffect rejestruje wartość img.complete w momencie zapisania każdej kartki, więc można sprawdzić tę różnicę w szybkiej sieci, gdzie jest trudno ją dostrzec.
function fetchListing() {
return new Promise(r => setTimeout(() => r({
title: 'Sea-facing 2BHK, Bandra',
// a new seed every time → a fresh download every time
photo: `https://picsum.photos/seed/${Math.random().toString(36).slice(2)}/600/300`,
}), 300));
}function Listing({ promise, optOut, label }) {
const l = use(promise);
const img = useRef(null);
// runs at the moment the content is committed — was the photo already there?
useLayoutEffect(() => {
console.log(`${label}: photo decoded before reveal → ${img.current.complete}`);
}, [label]);
return (
<div className="card">
<img
ref={img}
src={l.photo}
width={600}
height={300}
alt=""
onLoad={optOut ? () => {} : undefined}
/>
<strong>{l.title}</strong>
</div>
);
}function Slot({ label, optOut }) {
const [promise, setPromise] = useState(null);
return (
<div>
<button onClick={() => startTransition(() => setPromise(fetchListing()))}>{label}</button>
{promise && (
<ViewTransition update="auto" default="none">
<Suspense fallback={<div className="card skeleton"><div className="ph" /><strong>loading…</strong></div>}>
<Listing promise={promise} optOut={optOut} label={label} />
</Suspense>
</ViewTransition>
)}
</div>
);
}export default function App() {
return (
<div className="two">
<Slot label="Wait for photo" optOut={false} />
<Slot label="onLoad opt-out" optOut={true} />
</div>
);
}
Style określają układ dwóch kolumn oraz zachowują stosunek boków obrazu zarówno w kartce, jak i w elementzie zastępczym:
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.card { margin-top: 10px; padding: 10px; border-radius: 10px; background: light-dark(#f1f5f9, #262a33); }
.card img, .ph { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; border-radius: 6px; object-fit: cover; margin-bottom: 8px; }
.ph { background: light-dark(#e2e8f0, #363b47); }
.skeleton strong { color: light-dark(#64748b, #94a3b8); }
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Czcionki jako zasoby podlegające śledzeniu
Czcionki działają według tego samego modelu. Sekret polega na deklarowaniu reguły @font-face w elemencie <style> z atrybutami href i precedence, co każe Reactowi umieścić ją na szczycie listy i traktować jako zasób, na który można poczekać:
<ViewTransition update="auto" default="none">
<Suspense fallback={<QuoteSkeleton />}>
<style href={fontUrl} precedence="default">
{`@font-face {
font-family: 'Quote';
src: url(${fontUrl}) format('woff2');
font-display: swap;
}`}
</style>
<blockquote style={{ fontFamily: 'Quote' }}>{quote}</blockquote>
</Suspense>
</ViewTransition>
Dokumentacja React zawiera bardziej kompletny przykład, w którym dane pobrane z sieci, plik stylów z atrybutem precedence, niestandardowa czcionka oraz zdjęcie portretowe są umieszczone pod jednym wspólnym szkieletem i pokazywane razem. Taka kombinacja odpowiada większości rzeczywistych kart: profil, element przedstawiający produkt oraz wiadomość z czatem wraz z awatarem.
Jak to kiedyś robiono
Przed tym rozwiązaniem konieczne było ręczne wcześniejsze ładowanie treści, dopóki obraz nie był gotowy: tworzono element Image, czekano na zdarzenie onload, a następnie zmieniano stan aplikacji. Typowy hook wyglądał w ten sposób:
function useImageReady(src) {
const [ready, setReady] = useState(false);
useEffect(() => {
const img = new Image();
img.onload = () => setReady(true);
img.src = src;
}, [src]);
return ready;
}
Każdy komponent miał swoją własną kopię i żaden z nich nie koordynował się ze sobą, więc karta zawierająca zarówno zdjęcie, jak i niestandardową czcionkę nadal wyświetlała się w dwóch etapach. Sytuacja z czcionkami była jeszcze gorsza – wymagały one użycia document.fonts.load() w połączeniu z flagą lub font-display: block, co powodowało chwilowe wyświetlenie niewidzialnego tekstu. Aby zaanimować proces zamiany, trzeba było ręcznie wywołać startViewTransition po otrzymaniu danych, a ponieważ operacja ta przechodziła przez flushSync, nie mogła sama czekać na nic innego.
Dlaczego ta zmiana ma znaczenie
Ładowanie interfejsu staje się jedną decyzją zamiast czterech. Granica Suspense, którą już ustalono, określa również moment, w którym treść jest uważana za gotową – obejmuje ona dane, kod, style, czcionki i obrazy – a animacja uruchamia się wtedy tylko raz. Efekty związane z poszczególnymi zasobami znikają, podobnie jak kod łączący, który próbował je zsynchronizować.
Dzięki temu React ma wpływ na moment, w którym przeglądarka po raz pierwszy wyświetla załadowane zdjęcie. Wcześniej element <img> był wyświetlany natychmiast po dekodowaniu. Powiązanie go z przejściem między widokami daje efekt „pełnego wyświetlenia” charakterystyczny dla aplikacji natywnych, bez konieczności ręcznego wcześniejszego ładowania.
Kwestie, które należy mieć na uwadze
- Czyszczenie czcionek i obrazów następuje tylko podczas aktualizacji przy przejściu między widokami. Bez elementu
<ViewTransition>lub przy użyciu parametruupdate="none"obrazy pojawiają się tak jak wcześniej. - Dodanie obsługiwanego zdarzenia
onLoadwyklucza dany obraz z procesu czyszczenia, niezależnie od przejścia między widokami. Jest to przydatne dla obrazów znajdujących się poniżej linii scrollu, ale może być zaskakujące, jeśli to zdarzenie zostało dodane w celach analitycznych.
update="auto" default="none".next lub back, dodany do aktualizacji pokazującej rozwiązanie awaryjne, przestaje istnieć po zapisaniu rzeczywistego kontentu.Rozwiązanie awaryjne dla starszych wersji React
Jeśli nie możesz jeszcze zaktualizować oprogramowania, niezawodnym sposobem na uniknięcie nagłego pojawiania się obrazków jest traktowanie zdjęcia jako części tego, co ładujesz: należy to zrobić poprzez użycie Promise, który zostanie spełniony dopiero po otrzymaniu JSON i dekodowaniu zdjęcia:
function loadListing() {
return fetch('/api/listing').then(r => r.json()).then(async data => {
const img = new Image();
img.src = data.photo;
await img.decode().catch(() => {}); // don't let a broken image block forever
return data;
});
}
.catch(() => {}) w funkcji decode() zapewnia, że uszkodzony obraz nie może na zawsze blokować pokazania treści. Nadal następuje nagła zamiana zamiast animacji, ale obraz jest już w pamięci cache w momencie renderowania i pojawia się w tym samym kadrze co reszta karty. Czcionki można obsłużyć w podobny sposób, łącząc obietnice z wywołania document.fonts.load('1em Quote').
Główne wnioski
- Zawijanie mechanizmu Suspense w
<ViewTransition>animuje zamianę na treść awaryjną bez konieczności wyraźnego wywołaniastartTransitiondla pokazania treści. - Użyj parametrów
update="auto" default="none", aby treści awaryjne i z pamięci cache pojawiały się natychmiast, a animowana była tylko rzeczywista treść.
<ViewTransition> poza elementem Suspense dla pojedynczej animacji przejścia, lub wewnątrz obu gałęzi dla niezależnych animacji wejścia i wyjścia.width i height, pamiętając, że opcja onLoad wyłącza te ustawienia.Główne źródła: notatki z wersji React 19.3, dokumentacja do Suspense, dokumentacja do ViewTransition oraz dokumentacja do komponentu stylów.
Pozycje pokrewne
- Parallel Fetching w React: Suspense z Promise.all i allSettled — Dowiedz się, jak usunąć sekwencje zapytań za pomocą Promise.all, zapobiec awariom stron spowodowanym nieobowiązkowymi danymi dzięki Promise.allSettled oraz wyświetlać stany ładowania za pomocą Suspense.
- Deduplikowanie zapytań ORM podczas renderowania w Next.js za pomocą React cache() — Dowiedz się, dlaczego komponenty serwerowe umieszczone w tym samym miejscu mogą kilka razy na żądanie wysyłać te same zapytania do bazy danych, jak to sprawdzić oraz w jaki sposób React cache() rozwiązuje ten problem bez konieczności przechodzenia przez wiele poziomów właściwości.