Анімація напруги за допомогою ViewTransition: альтернативи, зображення, шрифти
Дізнайтеся, як використання ViewTransition для обгортки Suspense анімує заміну скелета контентом, які пропси запобігають зайвим анімаціям, та як React чекає на завантаження зображень та шрифтів.
Джерела: React 19.3 View Transitions with Suspense: Animate the Fallback, Wait for Images and Fonts
Межа Suspense вже описує перехід: вона показує запасний варіант, а потім замінює його на основний контент. Раніше ця заміна відбувалась різким переходом, часто після чого через мить з’являлась картинка, а ще пізніше — веб-шрифт. React 19.3 дозволяє обгорнути цю межу тегом <ViewTransition>, щоб заміна стала анімацією, і, хоча це менш помітно, змушує React відкласти цю анімацію до того часу, поки зображення та веб-шрифти у новому контенті не завантажаться. У цьому посібнику показано, як це налаштувати, як зупинити відтворення анімації у неправильний момент, де розміщувати межі та що насправді роблять вбудовані механізми очікування.
Оскільки ця функціональність була додана нещодавно, на момент написання цього тексту перевірте деталі у поточній документації React, перш ніж покладатися на точні таймінги у продакшені. У прикладах передбачається, що ви вже знаєте, що <ViewTransition> анімується під впливом певних подій (enter, exit, update) та що він має охоплювати DOM-елементи, які анімується.
Перетворення процесу показу на анімацію
Мінімальна версія просто розміщує <ViewTransition> навколо меж Suspense:
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
Вам не потрібен startTransition для заміни контенту, оскільки React вже розглядає відображення елемента Suspense як перехід. Як тільки Forecast завершує процес призупинки, операція заміни скелетного елемента виконується під час переходу між елементами відображення. Оскільки скелетний елемент та результат прогнозу знаходяться всередині одного й того самого <ViewTransition>, вони мають спільну назву знімка, тому зміна розглядається як update, і браузер плавно переходить від одного до іншого.
Приклад прогнозу, який можна запустити
Демонстрація починається з необхідних імпортів:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
Код компонента зберігає по одному об’єкту Promise на кожне місто у структурі Map, оскільки функція use() потребує однакової інстанції Promise під час кожного оновлення; створення нової інстанції щоразу призвело б до безкінечного затримки виконання. Функція Forecast читає вже оброблені дані за допомогою use(), компонент Skeleton відображає картку-замінник, а компонент App керує всім процесом у функції 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>
)}
</>
);
}
Кілька правил CSS форматують картку та надають компоненту Skeleton нейтрального кольору фону, використовуючи палітру light-dark() для обох схем кольорів:
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
Останнє правило уповільнює стандартний ефект плавного переходу, щоб зміни було легко помітити під час експериментування:
/* slow the default cross-fade so the swap is visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
При натисканні на кнопку відображається скелет, очікується приблизно секунда на імітоване отримання даних, після чого прогноз поступово з’являється поверх скелета. Однак якщо сховати його та знову завантажити, можна помітити проблему: дані тепер зберігаються у кеші, тому жоден запасний варіант не з’являється, але карта все одно поступово з’являється, так само як і зникала під час схову. Це веде до наступного розділу.
Зупинка анімацій, яких ви не просили
<ViewTransition> без параметрів анімує кожен тригер: enter під час завантаження, exit під час зняття та update щоразу, коли змінюється вміст. У поєднанні з Suspense це призводить до створення до трьох анімацій, хоча була задумана лише одна. У документації React описано бажану поведінку під час завантаження інтерфейсу:
- Запасний варіант має з’являтися негайно та без анімації, оскільки користувач щойно виконав дію та очікує миттєвої відповіді.
- Зміна з запасного варіанта на основний контент має супроводжуватися анімацією.
- Контент, який вже зберігається у кеші та тому ніколи не призупиняється, має з’являтися негайно та без анімації.
Два параметри точно виражають це. default="none" вимикає кожен тригер, а update="auto" увімкнює той, який вам потрібен:
<ViewTransition update="auto" default="none">
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
Порівняння двох конфігурацій
У другому демо використовуються ті самі імпорти:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
Це дозволяє завантажувати один із трьох міст та перемикатися між простою версією <ViewTransition> та версією з двома параметрами. Зверніть увагу на key={city} у елементі <ViewTransition>: при зміні міста створюється новий кордон замість оновлення існуючого.
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>
)}
</>
);
}
Шаблон стилю додає гнучку панель інструментів, а також ту саму картку, каркас та правила уповільненого переходу.
.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; }
При увімкненому режимі „тільки анімація оновлень“ місто, яке ще не завантажене, миттєво відображає свою структуру, а потім анімується до повноцінного вигляду, тоді як раніше завантажене місто просто з’являється без анімації. У документації рекомендується використовувати анімацію під час розкриття елементів типу Suspense лише іноді, а для кешованого контенту, який міг би бути відображений одразу, її зовсім не використовувати, оскільки це лише додає затримку. На практиці параметри update="auto" default="none" слід використовувати майже у кожному елементі <ViewTransition>, який оточує область Suspense.
Розташування визначає, які тригери будуть діяти
Місце розміщення елемента <ViewTransition> відносно області Suspense визначає, які тригери будуть активовані, а отже, які частини можна анімувати окремо.
За межами області: одне оновлення
Обгортання елемента з напругою ззовні створює єдиний межовий простір, який сприймає зміни як update:
<ViewTransition update="cross">
<Suspense fallback={<A />}>
<B />
</Suspense>
</ViewTransition>
Функція резерву та вміст мають однакову назву знімка, тому єдине, що можна зробити, — це поступово переходити від одного до іншого за допомогою спеціального класу cross.
У обох варіантах: вихід та вхід
Роздільне обгортання функції резерву та вмісту створює два незалежні межові простори:
<Suspense
fallback={<ViewTransition exit="out"><A /></ViewTransition>}
>
<ViewTransition enter="in"><B /></ViewTransition>
</Suspense>
Функція резерву виходить з анімацією exit, а вміст з’являється з анімацією enter, що дозволяє тимчасовому елементу опускатися вниз, поки справжній вміст піднімається на своє місце.
Картка профілю з використанням внутрішнього розташування
Третя демонстрація знову починається з імпортів:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
loadProfile створює нову обіцянку (Promise) з кожним натисканням, тому механізм Suspense припиняє виконання щоразу. Компоненти Profile та Skeleton кожен замінює свою структуру на власний елемент <ViewTransition>, використовуючи параметри enter="rise" та exit="sink" відповідно, з параметром default="none". Параметр key={attempt} у механізмі Suspense має важливе значення: він створює новий межовий елемент для кожного завантаження, оскільки вже наявний межовий елемент, який знову припиняє роботу всередині переходу, продовжує відображати попередній контент замість альтернативного.
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>
)}
</>
);
}
Базові стилі надають картці та елементу Skeleton певного вигляду:
.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); }
Правила анімації стосуються класів із визначеними іменами. ::view-transition-old(.sink) застосовується до старого знімка скелета елемента, а ::view-transition-new(.rise) — до нового вмісту; кожен з них має власні ключові кадри:
::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; } }
Очікування на зображення, шрифти та таблиці стилів
Механізм Suspense довгий час стримував завантаження даних та коду, який завантажується з відстрочкою. У React 19.3 набір елементів, які Suspense стримує під час анімації переходу, розширився, коли анімація здійснюється за допомогою <ViewTransition>:
- Дані, отримані за допомогою
use()та компонентів з відстроченим завантаженням: очікуються у обох випадках. - Таблиці стилів, оголошені за допомогою
<link rel="stylesheet" precedence="…">: очікуються у обох випадках (з таймаутом для звичайного Suspense).
<img>: ігноруються звичайним Suspense; під час оновлення під час переходу між елементами React очікує на декодування до 800 мс, за винятком зображень, які використовують loading="lazy" або мають обробник onLoad.Причина полягає у візуальному аспекті. Анімація переходу до картки, у якої фото навіть частково не завантажене або текст все ще використовує системний шрифт, виглядає гірше, ніж простий перехід. Тому React відкладає показ елемента під час переходу, доки зображення та нові шрифти в цьому елементі не стануть доступними. Без переходу між елементами нічого не змінюється, і зображення продовжують з’являтися в момент їхнього завантаження.
Скільки часу React готовий чекати
Час очікування є обмеженим. Ці деталі походять з аналізу реалізації react-dom, а не з документації, тому сприймайте їх як деталі реалізації, які можуть змінюватися:
- Для зображень React викликає
img.decode()та відкладає їх обробку на максимум 800 мс. - Він також оцінює об’єм кожного зображення за його атрибутами
widthтаheight, а швидкість мережі — за даними Resource Timing. Якщо ця оцінка показує, що завантаження займе більше приблизно 500 мс, React виділяє лише 50 мс та все одно виконує обробку. - Якщо атрибути
widthтаheightвідсутні, оцінка ґрунтується на припущенні про зображення розміром 100×100, тому реальні розміри роблять оцінку значущою. - Для шрифтів ліміт становить приблизно 500 мс.
Демонстрація часу очікування зображення
У демонстрації зображення імпортуються кілька додаткових хуків:
import { ViewTransition, Suspense, use, useState, useRef, useLayoutEffect, startTransition } from 'react';
Кожен клік завантажує запис із URL фото з випадковим семеном, тому зображення ніколи не кешується. Два елементи відображають однакову картку поруч одна з іншою. Ліва картка дозволяє React чекати на фото; права картка має порожній обробник onLoad, який відмовляється від завантаження зображення, тож картка відразу стає видимою, а фото з’являється пізніше. Функція useLayoutEffect фіксує значення img.complete у момент створення кожної картки, що дозволяє побачити різницю у швидкій мережі, де це важко помітити.
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>
);
}
Стилі визначають розташування двох колонок та зберігають співвідношення сторін зображення як у картці, так і в місцевому заміннику:
.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; }
Шрифти як ресурси для відстеження
Шрифти працюють за тим самим принципом. Секрет полягає у тому, щоб оголосити правило @font-face у елементі <style> разом із параметрами href та precedence, що дає React можливість використати його як ресурс, на отримання якого можна почекати:
<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>
У документації React є більш детальний приклад, у якому отримані дані, таблиця стилів з параметром precedence, власний шрифт та портретне фото розміщуються під одним спільним каркасом та відображаються разом. Ця комбінація відповідає більшості реальних карток: профілю, інформації про продукт, повідомленню в чаті з аватаром.
Як це робилося раніше
До цього для зберігання контенту до моменту готовності зображення потрібне було ручне попереднє завантаження: створювався об’єкт Image, чекалося на сигнал onload, після чого змінювався стан програми. Типовий хук виглядав так:
function useImageReady(src) {
const [ready, setReady] = useState(false);
useEffect(() => {
const img = new Image();
img.onload = () => setReady(true);
img.src = src;
}, [src]);
return ready;
}
Кожен компонент мав власну копію даних, і жоден з них не координував свою роботу, тому картка з фото та власним шрифтом все одно відображалася у два етапи. Ситуація з шрифтами була ще гіршою – для їх завантаження потрібно було використовувати document.fonts.load() разом із флагом або font-display: block, що спричиняло тимчасове відображення невидимого тексту. Для анімації заміни елементів доводилося вручну викликати startViewTransition після отримання даних, і оскільки цей процес проходив через flushSync, він не міг самостійно чекати на щось інше.
Чому ця зміна важлива
Завантаження інтерфейсу стає одним рішенням замість чотирьох. Межа Suspense, яку ви вже встановлюєте, тепер також визначає, коли контент вважається готовим – це стосується даних, коду, стилів, шрифтів та зображень – і анімація виконується лише один раз, саме у цю мить. Ефекти, пов’язані з кожним ресурсом, зникають разом із кодом-конектором, який намагався їх синхронізувати.
Це також дає React можливість впливати на момент, коли браузер уперше відображає завантажене зображення. Раніше <img> відображався одразу після декодування. Пов’язування його з переходом між відображеннями створює поведінку „повного відображення“, характерну для нативних додатків, без необхідності ручного попереднього завантаження.
Аспекти, які потрібно враховувати
- Шрифти та зображення очікуються лише під час оновлення через перехід між відображеннями. Без
<ViewTransition>або з параметромupdate="none"зображення з’являються так само, як і раніше. - Додавання обробника
onLoadвиключає це зображення з процесу очікування, незалежно від наявності переходу між відображеннями. Це корисно для зображень, які знаходяться поза видимою областю сторінки, але може здивувати, якщо обробник було додано для аналітики.
update="auto" default="none".next або back, додані до оновлення, яке відображало запасний варіант, більше не існують після того, як комітується справжній контент.Запасний варіант для старіших версій React
Якщо ви ще не можете оновитися, надійним способом уникнути раптового з’явлення зображення є розглядати його як частину того, що ви завантажуєте: призупинити виконання на Promise, який буде реалізований лише після того, як надійде JSON та зображення буде декодовано:
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(() => {}) у функції decode() гарантує, що пошкоджене зображення не зможе назавжди завадити його відображенню. Ви все одно отримуєте раптову заміну зображення замість анімації, проте до моменту відображення зображення вже знаходиться у кеші та з’являється в тому ж кадрі, що й решта елементів картки. Фонти можна обробляти аналогічним чином, склавши їх з Promise від document.fonts.load('1em Quote').
Основні висновки
- Використання
<ViewTransition>для обробки Suspense дозволяє анімувати заміну на запасний контент без необхідності явного викликуstartTransition. - Використовуйте параметри
update="auto" default="none", щоб запасний контент та кешований вміст з’являлися миттєво, а анімувалося лише справжнє відображення.
<ViewTransition> за межами Suspense для одноразового плавного переходу, або всередині обох гілок для окремих анімацій виходу та входу.width та height для зображень, пам’ятаючи, що опція onLoad вимикає це функціонування.Основні джерела: примітки до випуску React 19.3, документація Suspense, документація ViewTransition та документація компонента style.