Всем привет. Я Артем Курочкин, frontend разработчик компании DD Planet.
Сегодня я расскажу об одном из ключевых нововведений в React, представленных на React Conf 2025. Прошу любить и жаловать ViewTransition - нативная поддержка view transition api в экосистеме реакта.
Что это значит для React-разработчиков и как нам всем это поможет, мы и разберем в этой статье.
Что за зверь такой View Transition API
The View Transition API provides a mechanism for easily creating animated transitions between different website views. This includes animating between DOM states in a single-page app (SPA), and animating the navigation between documents in a multi-page app (MPA).
Если не углубляться в подробности, и сказать простым языком, данное API позволяет нам делать красивые анимации просто подключив это апи. Больше не нужно проводить часы вытирая слёзы, покадрово выверяя анимацию отрисованную дизайнером! Правда ведь? Ну, к этому мы вернемся в практической части.
Что касается всего это чуда и можно ли это тащить в прод.

И да, и нет. Все зависит от ваших требований. Хром вот поддерживает с 23 года, в то время как Firefox только в прошлом месяце включили флаг поддержки по умолчанию. Но тем не менее, это baseline 2025, поэтому еще годик-два и все будет, а изучить стоит уже сейчас.
И да, дела с MPA похуже, но мы все же говорим в контексте React разработки.
Ну и в целом, забегая вперед, стейбл релиз в React произойдет лишь в 19.3, а пока мы посмотрим на практике, как это все работает в canary ветке.
Как с этим работать в React
Рассмотрим самый базовый пример как все это завести, чтобы было красиво.
В React добавили компонент ViewTransition который является оберткой для ванильного ViewTransition интерфейса. Главное правило, без которого ничего не будет, — в нем обязательно должен лежать DOM-элемент.
<ViewTransition>
<div className="item">...</div>
</ViewTransition>
Ну все, погнали в прод? Ну, нет, чтобы анимация случилась, необходимо обернуть функцию, обновляющую DOM, в метод startTransition.
const handleUpdateDOM = () =>
startTransition(() =>
updateDOMSomethig()
)
И вот теперь все полетит. Но есть исключение: когда мы добавляем ноду ViewTransition, воспроизводится enter-анимация, а при удалении - exit.
function Child() {
return (
<ViewTransition>
<div>Hi</div>
</ViewTransition>
);
}
function Parent() {
const [show, setShow] = useState();
if (show) {
return <Child />;
}
return null;
}
Но при мапе ViewTransition, все же нужно вызывать startTransition.
А теперь давайте рассмотрим реальный пример, для этого я написал простую тудушку.
Вот код который реализует это.
//App.jsx
...
import { startTransition, useState } from "react"
import { Todo } from "./components/Todo"
function App() {
const [todos, setTodos] = useState([
{...},
])
const [isList, setIsList] = useState(false)
const addTodo = () =>
startTransition(() => {
...addTodoLogic
}
)
const updateTodos = (updatedTodo) => {
...updateTodoLogic
}
const deleteTodo = (id) =>
startTransition(() => {
...deleteTodoLogic
})
const handleView = () =>
startTransition(() => {
...handleViewLogic
})
return (
<div className={"wrapper"}>
<div className={"buttons"}>
<button onClick={addTodo}>Add</button>
<button onClick={handleView}>
{isList ? "Toggle Grid View" : "Toggle List View"}
</button>
</div>
<div className={clsx("container", { ["list"]: isList })}>
{todos.map((item) => (
<Todo
key={item.id}
item={item}
update={updateTodos}
onDelete={deleteTodo}
/>
))}
</div>
</div>
)}
export default App
//Todo.jsx
import { startTransition, ViewTransition } from "react"
import styles from "./todo.module.css"
export const Todo = ({ item, update, onDelete }) => {
const handleTodo = (value) =>
startTransition(() => {
...todoNameUpdateLogic
})
const handleComment = (value) => {
...todoCommentUpdateLogic
}
const handleComplete = (value) =>
startTransition(() => {
...todoCompleteLogic
})
const handleDelete = () => onDelete(item.id)
return (
<ViewTransition>
<div className={styles.wrapper}>
<div className={styles.row}>
<input
value={item.todo}
onChange={(event) => handleTodo(event.currentTarget.value)}
/>
<input
type='checkbox'
checked={item.complete}
onChange={(event) =>
handleComplete(event.currentTarget.checked)
}
/>
</div>
<textarea
value={item.comment}
onChange={(event) => handleComment(event.currentTarget.value)}
/>
<button onClick={handleDelete}>Delete</button>
</div>
</ViewTransition>
)}
В целом ничего сложного: один компонент обертка, и вызов метода на запуск transition, а на выходе достойный результат.
Главное не заигрывайтесь с анимациями и будьте внимательны, не вся верстка будет идеально работать.
Например, я ради эксперимента решил посмотреть, что будет с инпутом. Ответ - ничего хорошего. Даже если поставить время анимации 1мс, инпут будет терять интерактивность сильно дольше.
А что касается не идеальной работы, вот такие бывают артефакты :) (хотя, может, кому-то понравится)
Но это все цветочки, так или иначе это все можно было реализовать, написав кучку кода. А теперь перейдем к ягодкам.
Киллер фича
Барабанная дробь
Теперь можно анимировать переходы между страницами (да, стейт роутера тоже прекрасно анимируется). На этом у меня все, можно уходить (шутка).
А если серьезно, то задача такого рода (а никогда не знаешь, чего ждать от дизайнеров), ну, если раньше была не нереализуема, то точно на грани добра и зла. То теперь, пожалуйста, задача решается в одну строчку кода.
Весь код, чтобы достичь такого результата:
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<ViewTransition>
<Routes>
<Route path='/' element={<App />} />
<Route path='memes' element={<Memes />} />
<Route path='lorem' element={<Lorem />} />
</Routes>
</ViewTransition>
</BrowserRouter>
</StrictMode>,
)
Да, вы все правильно поняли, просто обернуть роутер в ViewTransition.
Так или иначе, кому-то будет мало простой cross-fade анимации. Неужели это все, что можно выжать из данного инструментария? Ответ: конечно нет!
Кастомизация
Вся кастомизация сводится к работе с CSS (да, все же его пописать придется, если хочется чего-то большего)
View Transition API вводит новые псевдо-элементы:

А у компонента ViewTransition есть пропсы, которые позволяют задать класс для общения с этими псевдо-элементами.

P.S. Там еще колбеки есть на enter, exit, update и share, но я не придумал им практического применения. Можете поделиться своими идеями в комментариях.
Ну так вот, вернемся к практике. Я немного поигрался с кастомизацией анимации тудушек.
Вот все телодвижения, что я совершил для этого:
return (
<ViewTransition enter='slide-in' exit='slide-out' update='update'>
...
</ViewTransition>
)
::view-transition-new(.slide-in){
animation: slide-in cubic-bezier(.83,.15,0,.98) 0.5s forwards;
}
::view-transition-old(.slide-out){
animation: slide-out cubic-bezier(.83,.15,0,.98) 0.5s forwards ;
}
::view-transition-group(.update){
animation-duration: 1s;
animation-timing-function: cubic-bezier(.83,.15,0,.98);
}
@keyframes slide-in {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
И отвечая на вопрос, который мог возникнуть у многих, кто сталкивался с анимациями: да, нас услышали - больше возится с тем, чтобы удалить элемент из DOMа только по завершению анимации не нужно.
Никаких больше CSSTransition и тому подобных либ, или что еще хуже, самим прописывать setTimeout-ы.
Если технически углубится, элементы теперь уничтожаются сразу, но появляется псевдо-элемент view-transition, в котором анимация и воспроизводится. C чем связан и не приятный бонус: к сожалению, пока воспроизводится анимация, взаимодействие с интерфейсом блокируется. Так что нужно соблюдать тонкую грань между плавностью/красотой и user friendly.
И добавлю, что для enter и exit не имеет смысла прописывать какие либо псевдо-элементы кроме new и old соответственно.
И если немного "пошаманить с бубном", можно получить красивые переходы между страницами.
<ViewTransition default='slide'>
...
</ViewTransition>
::view-transition-new(.slide){
animation: slide-in-rout cubic-bezier(.83,.15,0,.98) 1.5s forwards;
}
::view-transition-old(.slide){
animation: slide-out-rout cubic-bezier(.83,.15,0,.98) 1.5s forwards ;
}
@keyframes slide-in-rout {
from {
transform: translate(-100%, -50%) rotate(-90deg) scale(2);
filter: blur(50px);
opacity: 0;
}
to {
transform: translate(0, 0) rotate(0deg) scale(1);
filter: blur(0);
opacity: 1;
}
}
@keyframes slide-out-rout {
from {
transform: translate(0,0) rotate(0deg) scale(1);
filter: blur(0);
opacity: 1;
}
to {
transform: translate(100%, 50%) rotate(90deg) scale(.2);
filter: blur(50px);
opacity: 0;
}
}
Но и это не все, что мы можем накрутить. Можно выбирать разные анимации, в зависимости от действий юзера, так как в пропсы можно передавать не только строку, но и "словари". Чтобы переключить тип анимации, нужно использовать метод addTransitionType.
А вот и реализация
<ViewTransition
default={{
"navigation-left": "slide-left",
"navigation-right": "slide-right",
}}>
...
</ViewTransition>
...
<button
onClick={() =>
startTransition(() => {
addTransitionType("navigation-left")
navigate("/")
}) }>
{"<="} Go to todos{" "}
</button>
<button
onClick={() =>
startTransition(() => {
addTransitionType("navigation-right")
navigate("/memes")
}) }>
Go to memes {"=>"}
</button>
...
::view-transition-new(.slide-left){
animation: slide-in-left cubic-bezier(.83,.15,0,.98) 1.5s forwards;
}
::view-transition-old(.slide-left){
animation: slide-out-left cubic-bezier(.83,.15,0,.98) 1.5s forwards ;
}
@keyframes slide-in-left {
from {
transform: translate(-200%, -50%) scale(2);
}
to {
transform: translate(0, 0) scale(1);
}
}
@keyframes slide-out-left {
from {
transform: translate(0,0) scale(1);
}
to {
transform: translate(200%, 50%) scale(.2);
}
}
::view-transition-new(.slide-right){
animation: slide-in-right cubic-bezier(.83,.15,0,.98) 1.5s forwards;
}
::view-transition-old(.slide-right){
animation: slide-out-right cubic-bezier(.83,.15,0,.98) 1.5s forwards ;
}
@keyframes slide-in-right {
from {
transform: translate(200%, 50%) scale(2);
}
to {
transform: translate(0, 0) scale(1);
}
}
@keyframes slide-out-right {
from {
transform: translate(0,0) scale(1);
}
to {
transform: translate(-200%, -50%) scale(.2);
}
}
Fallbacks и оптимизация
Не нашел этому место в основном рассказе, но так же можно сделать красивые переходы между фолбеком и загрузившимся контентом.
Можно использовать для этого update или enter/exit. Реализуется это так:
//update
<ViewTransition>
<Suspense fallback={<A />}>
<B />
</Suspense>
</ViewTransition>
//enter/exit
<Suspense fallback={<ViewTransition><A /></ViewTransition>}>
<ViewTransition><B /></ViewTransition>
</Suspense>
И да, если у вас есть анимированный родитель, но у ребенка нужно убрать анимацию, в целях оптимизации или просто потому что, ребенка достаточно обернуть в еще один ViewTransition и в update прописать класс "none"
<ViewTransition>
<div className={theme}>
<ViewTransition update="none">
{children}
</ViewTransition>
</div>
</ViewTransition>
Заключение
В целом, у меня очень большие ожидания от данного API, и я очень рад, что это завезут в свежем React. Оно открывает двери в мир, где анимации реализуются на чистом CSS (почти), так как проблема с DOM ушла. Ну а для простеньких, так и в CSS лезть не надо, все из коробки: обернул что нужно, накинул методы для запуска и красиво.
Особенно это выделяется среди непонятного Activity и откровенного гениального костыльного решения проблемы ESlint в виде useEffectEvent, которые нас так же ждут в React 19.3
Ну, а на этом у меня все. Всем добра и позитива. Пишите хороший код.