Drive React 19.3: анимации входа и выхода при смене видов через JavaScript
Drive React 19 позволяет реализовывать переходы между видами с помощью JavaScript с использованием методов onEnter, onExit, onUpdate и onShare, а также Web Animations API, что невозможно с помощью CSS-классов.
Источники: React 19.3 View Transition Events: Drive Enter, Exit and Share Animations from JavaScript
Четыре события и то, что они передают
Классы переходов просмотра обрабатывают большинство анимаций: по одному классу на каждый триггер плюс ключевые кадры CSS. Они не могут справиться, когда анимация зависит от значений, известных только JavaScript — индекса списка, предпочтения пользователя, константы пружины. В таких случаях <ViewTransition> предоставляет четыре свойства событий, которые дают доступ к псевдоэлементам-скриншотам браузера, позволяя управлять ими с помощью API веб-анимаций. Этот материал основан на триггерах и типах переходов, рассмотренных ранее в серии статей React 19.3 View Transition.
<ViewTransition
onEnter={(instance, types) => { /* … */ return () => { /* cleanup */ }; }}
onExit={(instance, types) => { /* … */ }}
onUpdate={(instance, types) => { /* … */ }}
onShare={(instance, types) => { /* … */ }}
>
React генерирует по одному событию на каждую границу перехода, соответствующему выбранному триггеру; приоритет имеет onShare перед onEnter и onExit. Каждый обратный вызов получает:
instance.old— скриншот в состоянии «до» в виде::view-transition-oldс методом.animate()
instance.new — снимок в режиме «после» для ::view-transition-newinstance.group — контейнер ::view-transition-group, отвечающий за перемещение и изменение размеровinstance.imagePair — объект ::view-transition-image-pair, хранящий оба снимкаinstance.name — значение view-transition-name, присвоенное библиотекой React (или значение, заданное вами)types — типы переходов из функции addTransitionType, либо пустой массивФункция-обратный вызов выполняется после того, как обещание ready перехода выполнится — снимки уже готовы, применены стандартные анимации браузера и любые CSS-классы. Необходимо вернуть функцию для очистки. Когда начинается другой переход или документ скрывается, браузер прерывает выполнение; именно через функцию очистки анимация WAAPI, не завершенная до конца, отменяется.
Создание снимка состояния через WAAPI
Вызов метода .animate(keyframes, options) у объекта, созданного с помощью instance.new, ведет себя как метод элемента и возвращает объект типа Animation. Схема работы: запускается временная шкала, объект сохраняется, а затем его удаляют в процессе очистки.
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateY(24px) scale(.96)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: 380, easing: 'cubic-bezier(.2,.9,.25,1.15)', fill: 'both' }
);
return () => anim.cancel();
}}
CSS и JavaScript могут совместно выполнять задачи. Переходы между состояниями реализуются в CSS (enter="auto", exit="auto"), в то время как движение объекта — с учетом возможного превышения целевой точки, настраиваемого с помощью слайдера — обрабатывается JavaScript.
import { ViewTransition, useState, startTransition } from 'react';
export default function App() {
const [show, setShow] = useState(false);
const [bounce, setBounce] = useState(1.15); return (
<>
<div className="bar">
<button onClick={() => startTransition(() => setShow(s => !s))}>
{show ? 'Dismiss' : 'Show'} notification
</button>
<label>overshoot <input type="range" min="1" max="1.6" step="0.05" value={bounce}
onChange={e => setBounce(Number(e.target.value))} /> {bounce}</label>
</div> {show && (
<ViewTransition
enter="auto"
exit="auto"
default="none"
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateY(28px) scale(.94)' }, { transform: 'none' }],
{ duration: 420, easing: `cubic-bezier(.2,.9,.25,${bounce})`, fill: 'both' }
);
return () => anim.cancel();
}}
onExit={(instance) => {
const anim = instance.old.animate(
[{ transform: 'none' }, { transform: 'translateY(28px) scale(.94)' }],
{ duration: 220, easing: 'ease-in', fill: 'both' }
);
return () => anim.cancel();
}}
>
<div className="toast">🔔 Your build finished in 42 s</div>
</ViewTransition>
)}
</>
);
}
.bar { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.toast { margin-top: 12px; padding: 12px 16px; border-radius: 10px; background: light-dark(#fef3c7, #4a3410); }
/* the fade is CSS; slow the UA default so both layers are visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 420ms; }
Значение слайдера хранится в замыкании компонента, поэтому событие может получить его во время анимации без использования CSS-переменных или вычисляемых имён классов.
То, чего не может CSS: последовательные действия на основе замыкания
Для постепенного отображения каждого элемента необходим индекс. В CSS это обычно означает использование пользовательского свойства --i и функции calc() для задания задержек, причем эти значения никогда не доходят до псевдоэлементов view-transition. Внутри события индекс уже находится в области видимости:
import { ViewTransition, useState, useDeferredValue } from 'react';
const RESULTS = ['Frontend Master — Mock interviews', 'React 19.3, explained', 'View Transitions, part 5', 'Fragment Refs', 'use(browser())'];function Result({ text, index }) {
return (
<ViewTransition
default="none"
enter="auto"
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateX(-16px)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: 300, delay: index * 70, easing: 'ease-out', fill: 'both' }
);
return () => anim.cancel();
}}
>
<li>{text}</li>
</ViewTransition>
);
}export default function App() {
const [q, setQ] = useState('');
// the input stays urgent; the list follows a deferred value, which is a Transition
const deferredQ = useDeferredValue(q);
const hits = deferredQ ? RESULTS.filter(r => r.toLowerCase().includes(deferredQ.toLowerCase())) : [];
return (
<>
<input placeholder="type 'react' or 'r'" value={q} onChange={e => setQ(e.target.value)} />
<ul className="hits">
{hits.map((h, i) => <Result key={h} text={h} index={i} />)}
</ul>
</>
);
}
input { width: 100%; box-sizing: border-box; }
.hits { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 6px; }
.hits li { padding: 8px 12px; border-radius: 8px; background: light-dark(#f1f5f9, #262a33); }
/* keep the UA fade out of the way; the JS animation owns opacity too */
::view-transition-new(*) { animation: none; }
Важны два момента. Установка ::view-transition-new(*) { animation: none } устраняет эффект плавного перехода браузера, так что полупрозрачность полностью контролируется JavaScript; в противном случае эффекты плавного перехода накапливаются. Также важен параметр fill: 'both' для анимаций с задержкой: без него изображение остается полностью прозрачным во время задержки, а затем меняет состояние при начале анимации.
Использование типов перехода внутри обратного вызова
Вторым аргументом является список, генерируемый функцией addTransitionType. Его можно рассматривать как программируемую альтернативу таблицам классов CSS, когда необходимо рассчитать продолжительность или направление анимации.
onUpdate={(instance, types) => {
const duration = types.includes('fast') ? 120 : 400;
const dir = types.includes('back') ? -1 : 1;
const anim = instance.new.animate(
[{ transform: `translateX(${dir * 40}px)`, opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration, easing: 'ease-out', fill: 'both' }
);
return () => anim.cancel();
}}
Когда маппинг представляет собой простое поиск по таблице, рекомендуется использовать формат класс-объект, чтобы анимации оставались в CSS и были доступны для анализа. Обращайтесь к событию, когда необходимо выполнить какие-либо вычисления.
Маршрутизаторы: Navigation API и кнопка «Назад»
Два правила маршрутизации вызывают затруднения у команд, реализующих переходы между видами в клиентских маршрутизаторах.
Перед запуском анимации React ждет, пока навигация не завершится. Маршрутизаторы, которые приостанавливают выполнение события navigate из Navigation API до окончания отрисовки, должны освободить эту обещание внутри функции useLayoutEffect. Если освобождение происходит в функции useEffect, возникает цикл: React ждет завершения навигации, навигация ждет выполнения эффекта, запланированного только после перехода, а инструменты разработки выдают предупреждение о замедлении навигации.
История развития с использованием классического метода popstate также не поддерживает анимированные переходы. Такие навигации должны выполняться синхронно, чтобы прокрутка и восстановление форм оставались корректными, а синхронные операции не могут включать переходы между видами — поэтому React исключает анимацию. Лучше использовать обработчик API навигации navigate, который позволяет обрабатывать навигацию вперед/назад асинхронно:
useEffect(() => {
if (!window.navigation) return; // Chrome 102+, Safari 26.2, Firefox 147
const onNavigate = (event) => {
if (!event.canIntercept) return;
event.intercept({
handler() {
return new Promise((resolve) => {
startTransition(() => {
addTransitionType(event.navigationType === 'traverse' ? 'navigation-back' : 'navigation-forward');
setPath(new URL(event.destination.url).pathname);
});
resolveWhenPainted.current = resolve; // resolved from a useLayoutEffect after commit
});
},
});
};
window.navigation.addEventListener('navigate', onNavigate);
return () => window.navigation.removeEventListener('navigate', onNavigate);
}, []);
Роутеры в продакшене также фильтруют изменения хеша внутри одного документа, загрузки и запросы к разным источникам; от библиотек ожидается, что они обеспечат такую интеграцию, вместо того чтобы каждое приложение создавало её заново.
Более ранний ручной подход
До появления этих событий команды всё ещё использовали WAAPI с опцией pseudoElement для управления псевдоэлементами:
const vt = document.startViewTransition(() => flushSync(update));
await vt.ready;
document.documentElement.animate(
[{ transform: 'translateY(24px)' }, { transform: 'none' }],
{ duration: 380, pseudoElement: '::view-transition-new(toast)' }
);
Такой подход требовал ручного указания уникальных имён, заставлял предполагать, какой триггер сработал, поскольку React не выдавал никаких сигналов, и требовал точного синхронизирования действий с моментом готовности элемента ready. В то время как атрибуты событий предоставляют автоматически определённое имя триггера, информацию о типах данных, которые применяются, а также хук, срабатывающий один раз после готовности скриншотов.
Лучше всего оставлять короткие анимации затемнения и изменение макета в CSS, а обработчики событий использовать для значений, которым действительно необходимы закрытия функций — индексов, координат указателя, параметров пружин или значений длительности, определённых флагами функций. Такой подход позволяет сохранять стили, удобные для работы в DevTools, для обычных сценариев, при этом сохраняя возможность использования WAAPI там, где CSS-переменные в динамическом DOM никогда не достигают уровня скриншотов.
Когда демо-версии используют один и тот же слайдер или поле поиска с анимированным списком, состояние следует размещать в том же компоненте, который отрисовывает <ViewTransition>. Тогда функция-закрытие событий будет получать актуальные значения без необходимости в глобальном реестре анимаций. Если несколько элементов должны анимироваться одновременно, передайте индекс в качестве свойства маленькому дочернему компоненту, у которого есть собственная функция onEnter, вместо того чтобы вычислять задержки в родительском компоненте, который не может видеть каждый отдельный экземпляр анимации.
Почему это важно
Декларативные системы анимаций в конечном итоге нуждаются в императивных механизмах для решения сложных ситуаций. Дизайн таких механизмов определяет, откажутся ли команды от библиотеки при первом же крайнем случае. Здесь объём работы невелик — четыре функции-обратного вызова и один объект экземпляра; анимации комбинируются с CSS, а не заменяют его, а механизм очистки предотвращает оставление замороженных состояний анимаций.
Логика анимации также находится рядом с элементом, который ею управляет, в пределах его состояния компонента. Не существует глобального реестра имен, а также не требуется поиск ссылок после монтирования.
Особенности
- Всегда возвращайте функцию для очистки. Забывание строки
return () => anim.cancel()приводит к тому, что снимок остается отображаемым до окончания собственного временного ряда после прерывания. - События обрабатываются после стандартных действий. Эффекты плавного перехода между браузерами и ключевые кадры класса View Transition уже применены; анимация свойства
opacityв JS будет наложена поверх них, если только CSS не установит для данного псевдоэлемента значениеanimation: none. - Одно событие на каждую границу при переходе. Общие элементы вызывают функцию
onShareтолько один раз — не при входе и не при выходе.
instance.old всё равно существует. Вызов метода instance.old.animate() в функции onEnter направлен на псевдоэлемент, который браузер никогда не создавал, поэтому операция выполняется бесшумно без результата. Анимируйте new при входе и old при выходе.flushSync полностью пропускает переход, поэтому события никогда не срабатывают. То же происходит с скрытым документом.matchMedia('(prefers-reduced-motion: reduce)') внутри обратной связи; CSS-запросы к медиа-среде не влияют на временные шкалы WAAPI.popstate никогда не сопровождаются анимацией. Проверьте наличие функции window.navigation перед тем, как обещать анимированную историю навигации.Если вы пока не можете этим воспользоваться
Ручной путь WAAPI работает с любой версией React в браузерах, которые поддерживают метод document.startViewTransition. Его необходимо обернуть один раз:
import { flushSync } from 'react-dom';
export async function transitionWith(update, animateFn) {
if (!document.startViewTransition) { update(); return; }
const vt = document.startViewTransition(() => flushSync(update));
await vt.ready;
const anim = animateFn((pseudo) => ({
animate: (kf, opts) => document.documentElement.animate(kf, { ...opts, pseudoElement: pseudo }),
}));
vt.finished.finally(() => anim?.cancel());
}// usage — you own the names
transitionWith(
() => setShow(true),
(get) => get('::view-transition-new(toast)').animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300 })
);
При этом триггеры и типы анимации теряются, и вам придётся самостоятельно устанавливать значение view-transition-name. При обновлении кода функция animateFn обычно переходит в метод onEnter с незначительными изменениями.
Источники
- Документация React
<ViewTransition>о событиях, анимациях на JavaScript и интеграции с маршрутизатором - Руководство по использованию функции
addTransitionTypeдля настройки анимаций с помощью событий - Статья MDN о методе
Element.animate()и опцииpseudoElement - Справочник API навигации MDN
Команды, использующие React 19.3 для реализации переходов между видами, должны рассматривать API событий как вспомогательный инструмент, а не стандартное решение. Начните с классов и типов; переходите к использованию onEnter / onExit / onUpdate / onShare только тогда, когда вычисления или использование замыканий становятся неизбежными. Такой подход позволяет оставлять большинство анимаций видимыми в панели стилей, одновременно обеспечивая поддержку случаев, которые невозможно описать с помощью CSS.