Всем привет. Я Артем Курочкин, 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
Ну, а на этом у меня все. Всем добра и позитива. Пишите хороший код.
