Исторически создание плавных переходов между экранами (Page Transitions) и кинематографического морфинга элементов в Single Page Applications (SPA) требовало колоссальных накладных расходов. JavaScript-библиотекам анимаций приходилось одновременно удерживать в оперативной памяти две независимые копии DOM-дерева: старый экран и монтируемый новый экран. Это приводило к удвоению потребления памяти, сложным хакам с position: absolute и непрерывным вызовам requestAnimationFrame в Main Thread для интерполяции координат.
В архитектуре Server-Side Rendering (SSR) и React Server Components (RSC) этот подход становился непреодолимым барьером: сервер не может «одновременно отрендерить две страницы» для анимации перехода.
View Transitions API переносит ответственность за анимацию со слоя JavaScript-фреймворка напрямую в ядро браузера (Blink, WebKit, Gecko). Браузер мгновенно фиксирует растровый снимок старого состояния, замораживает кадр, дожидается обновления DOM в React 19, фиксирует снимок нового состояния и генерирует аппаратное дерево псевдоэлементов в Top Layer для GPU-интерполяции.
View Transitions API полностью освобождает JavaScript-поток от покадровой интерполяции геометрии. Браузер берет растровый снимок предыдущего экрана и плавно трансформирует его в живой новый экран силами видеокарты. Это дает нулевой вес бандла (0 КБ библиотек), отсутствие дублирования DOM-узлов и нативную поддержку переходов между страницами как в SPA, так и в классических многостраничных сайтах (MPA).
Ментально работу View Transitions API можно представить как двухкадровый кинематографический видеомикшер в операционной системе браузера:
┌─────────────────────────────────────────────────────────────┐
│ REACT 19 (Main Thread Execution) │
│ document.startViewTransition(() => { │
│ startTransition(() => setActiveScreen('checkout')); │
│ }); │
└──────────────────────────────┬──────────────────────────────┘
│
▼ 1. Захват снимка ДО мутации
┌─────────────────────────────────────────────────────────────┐
│ BROWSER TOP LAYER (GPU Compositor) │
│ │
│ ::view-transition │
│ └── ::view-transition-group(product-card) │
│ └── ::view-transition-image-pair(product-card) │
│ ├── ::view-transition-old(product-card) │
│ │ (Статичный растровый слепок ДО) │
│ └── ::view-transition-new(product-card) │
│ (Живой отрендеренный DOM-узел ПОСЛЕ) │
└─────────────────────────────────────────────────────────────┘
│
▼ 2. Аппаратный кроссфейд и FLIP на GPU
┌─────────────────────────────────────────────────────────────┐
│ Результат: 120 FPS, 0 КБ JS в бандле, нулевой Reflow │
└─────────────────────────────────────────────────────────────┘Когда вызывается document.startViewTransition(updateCallback), браузер создает в служебном слое Top Layer изолированное псевдодерево элементов:
::view-transition: корневой контейнер, перекрывающий все окно просмотра (Viewport).::view-transition-group(name): контейнер конкретной именованной группы. Именно для него браузер автоматически анимирует геометрические свойства transform, width и height.::view-transition-image-pair(name): обертка для пары старого и нового визуального представления.::view-transition-old(name): статичный растровый снимок элемента ДО изменения DOM.::view-transition-new(name): активный отрендеренный узел ПОСЛЕ изменения DOM.Элементам, положение которых должно интерполироваться индивидуально, присваивается уникальное CSS-свойство:
view-transition-name: product-hero-42;.
Код вызывает document.startViewTransition(callback). Браузер делает растровый скриншот текущего окна просмотра.
Отрисовка экрана приостанавливается. React 19 выполняет реконсиляцию внутри callback и синхронно применяет новый DOM.
Браузер считывает новое DOM-дерево, замеряет финальные координаты и формирует узел ::view-transition-new.
Браузер строит псевдодерево переходов в Top Layer и запускает аппаратный кроссфейд и интерполяцию геометрии на 120 FPS.
Какой псевдоэлемент в сгенерированном браузером дереве ::view-transition отвечает за анимацию изменения геометрических размеров (width, height) и координат (transform) между старым и новым положением элемента?
При переключении между темной и светлой темами экран не мерцает. Новая тема кругом раскрывается (радиальный clip-path) прямо из точки клика курсора мыши по кнопке переключателя через стилизацию ::view-transition-new(root).
Пользователь переходит из раздела просмотра фильма в каталог. Видеоплеер плавно уменьшается и перемещается в нижний правый угол с сохранением непрерывного декодирования потока благодаря свойству view-transition-name: media-player.
При клике на вкладку фильтра карточки товаров плавно меняют позиции, заголовок категории интерполируется, а строка поиска остается зафиксированной без лишних монтирований DOM.
Переход от шага «Доставка» к шагу «Оплата» с плавным горизонтальным скольжением блоков формы, в то время как правая колонка «Итого к оплате» сохраняет стабильное положение.
Главная сложность связки React с startViewTransition заключается в том, что браузер ожидает, пока переданный коллбек завершит синхронное обновление DOM. Если React отложит рендер в фоновую Concurrent-дорожку (Lanes), браузер сделает снимок до того, как DOM успеет измениться, и анимация сорвется.
Для решения этой задачи в React 19 функция обновления оборачивается в startTransition, а хук useViewTransitionState обеспечивает корректную координацию:
По умолчанию браузер применяет базовый кроссфейд (opacity: 1 -> 0 и 0 -> 1). Для создания уникальных эффектов разработчик переопределяет CSS:
/* Радиальное круговое раскрытие темы */
::view-transition-old(root) {
animation: none;
z-index: 1;
}
::view-transition-new(root) {
animation: circular-reveal 0.45s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 9999;
}
@keyframes circular-reveal {
from {
clip-path: circle(0% at var(--click-x, 50%) var(--click-y, 50%));
}
to {
clip-path: circle(150% at var(--click-x, 50%) var(--click-y, 50%));
}
}
/* Обязательное правило доступности: отключение переходов для чувствительных пользователей */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Разработчик добавил CSS-класс .card { view-transition-name: product-card; } ко всем 20 карточкам каталога интернет-магазина. Пользователь кликает по кнопке фильтрации категорий. Что произойдет при вызове document.startViewTransition()?
Построим законченную промышленную подсистему для React 19:
src/hooks/useViewTransitionState.ts: Реактивный хук обновления состояния с интеграцией document.startViewTransition и React.startTransition, поддерживающий безопасную деградацию в неподдерживаемых браузерах.src/components/theme/ThemeCircularRevealToggle.tsx: Компонент переключения цветовой темы с геометрической привязкой центра кругового раскрытия к клику курсора мыши.src/components/gallery/ProductCatalogMorph.tsx: Витрина товаров с морфингом карточки в детальный экран на нативных view-transition-name.'use client';
import * as React from 'react';
/**
* Проверка поддержки View Transitions API браузером
*/
export function isViewTransitionSupported(): boolean {
return typeof document !== 'undefined' && 'startViewTransition' in document;
}
/**
* Хук реактивного состояния с нативной браузерной анимацией перехода
*/
export function useViewTransitionState<T>(initialState: T) {
const [state, setState] = React.useState<T>(initialState);
const setViewTransitionState = React.useCallback(
(nextState: T | ((prev: T) => T)) => {
// Fallback для браузеров без поддержки: стандартный переход React 19
if (!isViewTransitionSupported()) {
React.startTransition(() => {
setState(nextState);
});
return;
}
// Нативный запуск браузерного перехода
document.startViewTransition(() => {
React.startTransition(() => {
setState(nextState);
});
});
},
[]
);
return [state, setViewTransitionState] as const;
}'use client';
import * as React from 'react';
import { isViewTransitionSupported } from '@/hooks/useViewTransitionState';
export function ThemeCircularRevealToggle() {
const [isDark, setIsDark] = React.useState(true);
const toggleTheme = (event: React.MouseEvent<HTMLButtonElement>) => {
const nextDark = !isDark;
if (!isViewTransitionSupported()) {
setIsDark(nextDark);
document.documentElement.classList.toggle('dark', nextDark);
return;
}
// Вычисляем координаты центра кнопки клика для точки старта анимации
const rect = event.currentTarget.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
document.documentElement.style.setProperty('--click-x', `${x}px`);
document.documentElement.style.setProperty('--click-y', `${y}px`);
const transition = document.startViewTransition(() => {
setIsDark(nextDark);
document.documentElement.classList.toggle('dark', nextDark);
});
transition.ready.catch((err: unknown) => {
console.warn('[View Transition]: Прервано пользователем', err);
});
};
return (
<button
type="button"
onClick={toggleTheme}
className="px-4 py-2 rounded-xl bg-bg-surface border border-border text-xs font-mono font-bold text-text-primary hover:border-accent-violet transition-colors cursor-pointer shadow-sm"
>
Тема: {isDark ? '🌙 ТЕМНАЯ' : '☀️ СВЕТЛАЯ'}
</button>
);
}'use client';
import * as React from 'react';
import { useViewTransitionState } from '@/hooks/useViewTransitionState';
export interface ProductItem {
readonly id: string;
readonly name: string;
readonly price: number;
readonly category: string;
readonly description: string;
readonly color: string;
}
const PRODUCTS: readonly ProductItem[] = [
{
id: 'prod-1',
name: 'Механическая клавиатура Custom 75%',
price: 190,
category: 'Периферия',
description: 'Алюминиевый корпус, переключатели Gateron Oil King с заводской смазкой, hot-swap и шумоизоляция Poron.',
color: 'bg-violet-600',
},
{
id: 'prod-2',
name: 'Беспроводная мышь Ultralight',
price: 130,
category: 'Периферия',
description: 'Оптический сенсор 26 000 DPI, оптические микропереключатели, вес 52 грамма и частота опроса 4000 Гц.',
color: 'bg-cyan-600',
},
];
export function ProductCatalogMorph() {
const [selectedId, setSelectedId] = useViewTransitionState<string | null>(null);
const selectedProduct = PRODUCTS.find((p) => p.id === selectedId);
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
{!selectedProduct ? (
/* Сетка списка товаров */
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{PRODUCTS.map((product) => (
<div
key={product.id}
onClick={() => setSelectedId(product.id)}
className="p-6 rounded-2xl bg-bg-surface border border-border cursor-pointer hover:border-accent-violet/50 transition-colors shadow-md space-y-4"
>
<div
style={{ viewTransitionName: `badge-${product.id}` }}
className={`w-12 h-12 rounded-xl ${product.color} flex items-center justify-center font-mono font-bold text-white text-sm shadow-inner`}
>
#{product.id.slice(-1)}
</div>
<div>
<span className="text-[10px] font-mono uppercase tracking-wider text-accent-cyan font-bold block mb-1">
{product.category}
</span>
<h3
style={{ viewTransitionName: `title-${product.id}` }}
className="text-lg font-bold text-text-primary leading-snug"
>
{product.name}
</h3>
</div>
<div
style={{ viewTransitionName: `price-${product.id}` }}
className="text-base font-mono font-bold text-text-primary"
>
${product.price}
</div>
</div>
))}
</div>
) : (
/* Детальный экран выбранного товара */
<div className="p-8 rounded-3xl bg-bg-surface border border-accent-violet/40 shadow-2xl space-y-6">
<div className="flex items-start justify-between">
<div
style={{ viewTransitionName: `badge-${selectedProduct.id}` }}
className={`w-20 h-20 rounded-2xl ${selectedProduct.color} flex items-center justify-center font-mono font-extrabold text-white text-2xl shadow-xl`}
>
#{selectedProduct.id.slice(-1)}
</div>
<button
type="button"
onClick={() => setSelectedId(null)}
className="px-4 py-2 text-xs font-mono font-semibold rounded-xl bg-bg-elevated border border-border text-text-muted hover:text-white transition-colors cursor-pointer"
>
← Назад к каталогу
</button>
</div>
<div>
<span className="text-xs font-mono uppercase tracking-wider text-accent-cyan font-bold block mb-1">
{selectedProduct.category}
</span>
<h1
style={{ viewTransitionName: `title-${selectedProduct.id}` }}
className="text-2xl sm:text-3xl font-black text-text-primary"
>
{selectedProduct.name}
</h1>
<div
style={{ viewTransitionName: `price-${selectedProduct.id}` }}
className="text-2xl font-mono font-bold text-accent-violet mt-2"
>
${selectedProduct.price}
</div>
</div>
<p className="text-sm text-text-secondary leading-relaxed pt-4 border-t border-border">
{selectedProduct.description}
</p>
</div>
)}
</div>
);
}Если в CSS прописать .card { view-transition-name: product-card; }, на странице окажется несколько элементов с одинаковым идентификатором. Браузер не сможет сопоставить пары снимков и завершит вызов ошибкой InvalidStateError.
Решение: Назначайте view-transition-name динамически через inline-стили с уникальным ID: style={{ viewTransitionName: 'card-' + item.id }}.
Если внутри переданного коллбека запустить долгий асинхронный запрос к серверу await fetch(...), браузер заблокирует экран в состоянии замороженного кадра на несколько секунд.
Решение: Загружайте данные ДО старта перехода. Внутри startViewTransition должно выполняться только синхронное обновление состояния React.
Если старый элемент был квадратным (1:1), а новый стал прямоугольным (16:9), браузер растянет растровый снимок ::view-transition-old, вызвав кратковременное размытие шрифтов и искажение пропорций.
Решение: Для таких элементов явно прописывайте правило сохранения пропорций: object-fit: cover; width: 100%; height: 100%;.
Воспроизведение масштабных экранных трансформаций у пользователей с вестибулярными расстройствами является грубым нарушением стандартов WCAG 2.2.
Решение: Всегда добавляйте правило @media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }.
::view-transition-group (отвечающего за геометрию transform/width/height), растрового снимка ::view-transition-old и актуального нового узла ::view-transition-new.view-transition-name обязан быть строго уникальным в пределах документа во избежание ошибки InvalidStateError.document.startViewTransition с React.startTransition, а сетевые запросы обязаны выполняться до инициации перехода.@media (prefers-reduced-motion: reduce) является обязательным для обеспечения доступности (a11y).