Анимация ожидания с использованием 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 форматируют карточку и придают заглушке нейтральный фон, используя функцию 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 определяет, какие триггеры будут активированы, а значит, какие части можно анимировать отдельно.
Вне границ: одно обновление
Обертывание элемента с эффектом напряжения снаружи создает единый границу, которая воспринимает изменения как обновление:
<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 имеет большое значение: он создает новый механизм ограничения для каждой загрузки, поскольку уже существующий механизм ограничения, который снова приостанавливает работу внутри элемента Transition, продолжает отображать предыдущий контент вместо фонового содержимого.
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 список элементов, загрузка которых задерживается из-за использования <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
Если вы пока не можете обновиться, надежным способом избежать мгновенного отображения изображений является рассмотрение картинки как части загружаемого контента: приостановить выполнение до тех пор, пока не поступит 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 и документация к компоненту стилей.