Історично створення плавних переходів між екранами (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): обгортка для пари старого та нового візуального представлення (isolation: isolate).::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, що забезпечує коректну координацію:
За замовчуванням браузер застосовує базовий кросфейд (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).