Содержание
Коротко
Сетка снимков и крупный кадр — как две страницы одного альбома на столе. Палец попадает в маленькую карточку, она должна вырасти в большой отпечаток, а книжка не захлопывается.
В React 19.3 для этого есть стабильный компонент <ViewTransition>. Он ждёт, пока дерево реально перерисуется, и только потом просит браузер снять кадр «после».
Одинаковое имя у миниатюры и у крупного изображения говорит, какой старый элемент заменяет новый, даже если за тот же щелчок сменились фильтр и выбранный идентификатор.
Что произошло
Разбор сверен с React 19.3.0. В журнале изменений на GitHub от 9 сентября 2026 года <ViewTransition> и addTransitionType помечены как стабильные, не как опытная сборка. Текст рассчитан на функциональные компоненты и хуки этой эпохи React.
Браузерный API переходов вида умеет узкую схему. Вызов document.startViewTransition() оборачивает синхронную правку DOM. Старый и новый элементы делят view-transition-name, и браузер плавно ведёт один прямоугольник в другой.
Щелчок по миниатюре в React почти никогда не бывает такой правкой. Вызов вроде setSelectedId не трогает DOM: он только просит перерисовку. К моменту, когда список, строка поиска и выбранный снимок уже согласованы, браузер часто успел снять «после» со старого дерева. Сравниваются два одинаковых кадра. Движение дёргается, цепляется не за тот элемент или не начинается, и инструменты разработчика это не объясняют.
Обход через flushSync заставляет отрисовку случиться сразу, внутри переданной функции. Цена — синхронный блокирующий проход, как раз то, от чего уходит одновременная отрисовка React. И даже тогда браузер не знает, какая миниатюра в отфильтрованном списке соответствует крупному кадру: он растворяет весь контейнер.
<ViewTransition> закрывает зазор. Компонент смотрит на собственную фиксацию React в DOM и на ожидание Suspense, а не предполагает одну синхронную мутацию.
В статье отдельно оговорено: «переход» здесь не синоним анимации. Это та же пометка обновления, что у startTransition и useTransition. Обновление можно прервать, оно не самое срочное. Обёртка реагирует только на такую пометку. Обычный setState без неё рисует мгновенно, как будто компонента нет. Подойдут прямой startTransition, useDeferredValue или маршрутизатор, который сам помечает смену страницы.
Когда обновление помечено, каждый обёрнутый узел попадает ровно в одну из четырёх форм. Это не четыре разных библиотеки движения, а четыре ответа на вопрос «что случилось с этим куском дерева».
| Форма | Когда срабатывает |
|---|---|
enter |
узел впервые вставлен в дерево за время этого перехода |
exit |
узел впервые убран |
update |
узел остался, но изменились содержимое, размер или положение, часто из‑за соседа |
share |
именованный узел исчез в одном поддереве и появился с тем же name в другом |
Последняя строка — как раз альбом: маленькая карточка и большой отпечаток для согласования дерева разные узлы, а для глаза это один снимок.
Почему это важно
Щелчок по сетке не заменяет документ целиком. Вместе с выбранным снимком могут закрыться поиск, смениться страница списка, а данные ещё не приехать. Браузерный вызов ждёт, что код внутри функции сам и сразу меняет DOM. React описывает намерение. Фиксация может быть отложена, собрана в одну пачку или удержана, пока Suspense не получит данные.
Пока эта разница не закрыта, общий элемент живёт только на статичной странице. Список становится динамическим — и миниатюра с крупным кадром для браузера уже чужие узлы. Плавный рост рассыпается.
Имя на обеих сторонах делает тождество явным. При обычном согласовании React отличает повторную отрисовку узла от размонтирования, но два разных поддерева сам не склеит. Форма share отвечает на вопрос «это тот же кадр?» именем, когда собственной идентичности узла не хватает.
Ярлык addTransitionType не решает, будет ли движение. Форму по-прежнему выбирают enter, exit, update и пара с общим name. Ярлык только называет причину, чтобы CSS отличил шаг вперёд от шага назад без лишнего свойства, протянутого по дереву.
С той же версии 19.3 несколько таких обновлений больше не слипаются в один проход. Второе, постороннее, не обязано ждать, пока первое медленное ещё считается.
Компилятор React стабильной версии 1.0 к этому не относится. Он запоминает значения, посчитанные при отрисовке, и не меняет ни эти переходы, ни Suspense, ни жизненный цикл браузерного перехода вида.
На практике
Компонент уместен там, где обновление и так идёт через откладываемый переход: смена маршрута, фильтр, вкладка, оптимистичное обновление. Имеет смысл, если результат должен ощущаться непрерывным. Перестановка списка, раскрытие карточки, миниатюра, которая становится крупным кадром.
Именовать стоит только то, что глаз должен узнать. Именованная обёртка на каждом узле — учёт движений, которые никто не заметит.
Не заворачивайте обновление в startTransition только ради анимации. Если изменение срочное и его нельзя прерывать, честнее обычное правило перехода CSS на самом элементе.
Смена целиком загруженных документов по-прежнему задача правила @view-transition { navigation: auto; }, не этого компонента.
Миниатюра и крупный кадр из статьи связаны так:
function Thumbnail({ photo, onSelect }) {
return (
<ViewTransition name={`photo-${photo.id}`}>
<img
src={photo.thumbUrl}
onClick={() => startTransition(() => onSelect(photo.id))}
/>
</ViewTransition>
);
}
function DetailView({ photo }) {
return (
<ViewTransition name={`photo-${photo.id}`}>
<img src={photo.fullUrl} className="detail-image" />
</ViewTransition>
);
}
Если за один переход узел с этим именем исчез, а другой с тем же именем появился, браузер ведёт положение и размер от маленькой картинки к большой. Фильтр и сортировка в том же щелчке не мешают: тождество задано именем, а не местом в массиве. Так альбом остаётся открытым. Маленький отпечаток вырастает в большой, потому что страница уже переложена.
Появление и исчезновение требуют меньше всего настройки. Свойства enter и exit принимают auto (растворение, которое браузер ставит сам), none или имя класса. Стили пишутся на псевдоэлементах ::view-transition-new и ::view-transition-old. Сами вы не вызываете document.startViewTransition: React решает, когда звать браузер.
Узел, который остаётся смонтированным, но меняет высоту, оформляют через update, не через появление и исчезновение. Сосед, которого сдвинули чужим изменением размера, тоже получает update: сдвиг положения входит в это определение.
Строка, переданная в addTransitionType, уходит в тип браузерного перехода. Селектор :active-view-transition-type() совпадает только с корнем документа, поэтому правило псевдоэлемента вкладывают внутрь:
:root:active-view-transition-type(nav-back) {
&::view-transition-old(.card) {
animation: slide-out-right 200ms;
}
}
Края, о которых просит помнить исходный разбор:
- Обёртка вокруг изменения вне перехода молчит. Чаще всего виноват обычный
onClick={() => setState(x)}, а не забытое свойство. - Имя в каждом поддереве в момент обмена должно быть уникальным, как
view-transition-nameу браузера. Два одновременно смонтированных узла с одним именем — ошибка: в разработке React пишет в журнал оба. Берите устойчивый идентификатор снимка, а не порядковый номер в массиве. - Содержимое, которое держит
Suspense, обычно откладывает снимок «после», а не ломает само движение. React ждёт готовности, чтобы не анимировать в индикатор загрузки. Если между исчезновением и появлением пары с общим именем всё же показался запасной интерфейс, общего перехода для этой пары не будет. - Без поддержки API в браузере компонент должен мгновенно обновить экран, а не бросить ошибку. В статье поддержка отнесена к Chrome и Safari, в остальных местах остаются пропуски. Проверьте свои целевые браузеры и не давайте отсутствующему API сломать само обновление.
- Это не библиотека движения общего назначения: нет физики пружин, поочерёдного запуска и временной шкалы. Вы стилизуете два псевдоэлемента CSS. Всё, что шире появления, исчезновения, изменения размера и общего элемента, по-прежнему отдайте отдельной библиотеке.
Итог
Сбой в сетке был не в идее плавного кадра. Синхронный браузерный вызов просили описать отложенное обновление React.
startTransition сообщает, что изменение вообще стоит показывать в движении. Одинаковое имя сообщает, какую миниатюру заменяет крупный кадр, даже если фильтр перетасовал список.
Альбом после этого не захлопывается: маленький снимок вырастает в большой уже на новой странице, без flushSync и без ручного выбора момента снимка.



Комментарии