Введение
Полистав различные ресурсы в Интернете и просмотрев множество видео по данной теме, я решил составить наиболее полную картину по данному, не побоюсь этого слова, революционному API, чтобы вы уже сегодня могли начать использовать его в своём проекте!
Ссылка на мой блог в телеграм с новыми фичами и лайфхаками во Frontend.
Ссылка на другие мои статьи.
Приятного прочтения!
Полезные ссылки
vtbag - ресурс, на котором вы сможете проверить поддержку фич View Transition API в вашем браузере, а также найти множество инструментов, примеров и советов по этой теме.
Предназначение
Данный API был разработан для того, чтобы можно было реализовывать плавные переходы между страницами, а также изменениями DOM-дерева. В большинстве современных библиотек вроде React или Vue, в SPA приложениях вам уже доступны эти фичи благодаря другим библиотекам или встроенным возможностям, но в MPA до недавнего времени это было невозможно.
С чего начать?
В качестве примера возьмём простое приложение, состоящее из четырёх страниц, переключаемых пагинацией.
На видео ниже видно, что переходы на данном этапе отсутствуют вовсе:
Для того, чтобы всё начало работать, достаточно добавить вот такой незамысловатый блок CSS кода на обе страницы, между которыми планируется переход:
@view-transition {
navigation: auto;
}
Таким образом, мы разрешаем использование View Transition API, а также включаем стандартный браузерный переход постепенного появления новой и исчезновения старой страниц:
По-умолчанию у свойства navigation установлено значение none, которое запрещает переходы между страницами. Каких-либо других значений на данный момент для него не предусмотрено.
Как всё устроено под капотом?
Перед тем как идти дальше, важно разобраться с механизмом работы этого API.
На скриншоте ниже представлено дерево псевдоэлементов, генерируемых при переходе с одной страницы на другую:

Они появляются в DOM дереве именно в момент перехода и, как только анимация заканчивается, немедленно пропадают из него. Поэтому, чтобы их можно было нормально рассмотреть, вам нужно открыть вкладку Animations, как показано на видео ниже, и приостановить процесс анимации соответствующей кнопкой:
Не забывайте, что для работы переходов вам понадобится директива @view-transition на обоих страницах.
Итак, начнём разбор.
В самом корне у нас находится ::view-transition, внутри которого может быть несколько ::view-transition-group, внутри которых всегда находится по одному ::view-transition-image-pair, и, наконец, внутри которого также находится по одному экземпляру ::view-transition-old и ::view-transition-new.
Но что они все означают?
При создании перехода мы можем создать не одну анимацию, а сразу несколько для разных элементов дерева DOM. В примере выше мы пользовались переходом по-умолчанию, поэтому был создан только один ::view-transition-group для root элемента. root в данном контексте это то же самое, что и :root селектор в CSS, а именно <html> тег страницы. Если бы мы определили сразу несколько переходов, то в корне ::view-transition появилось бы сразу несколько ::view-transition-group элементов. Позже разберём, как это можно сделать.
Заранее скажу, что всеми этими псевдоэлементами вы можете управлять при помощи CSS кода, но нам сейчас важно понять другое. В момент, когда происходит переход между двумя страницами, фиксируется последнее состояние старой страницы, а также в фоне простраивается состояние новой. В официальной документации эти состояния называются снапшотами (это те самые ::view-transition-old и ::view-transition-new). Затем начинается их анимация. Важно понимать, что new, как слой, всегда идёт поверх old. Они оба, единовременно анимируются и создётся эффект плавного перехода. В конце анимации всё дерево этих псевдоэлементов удаляется и остаётся только обновлённое DOM дерево новой страницы.
Образовавшиеся снапшоты под капотом являются replaced elements. То есть, они работают аналогично элементам
<img>,<video>или<iframe>.
Нафиг нам ::view-transition-image-pair?
Внимательный читатель, возможно, задастся вопросом:
— Если во ::view-transition-group может быть только один ::view-transition-image-pair, то зачем нам вообще нужно 2 вложенных друг в друга псевдоэлемента вместо одного? Наверняка же не для общих стилей new и old, т.к. их можно задать что одному, что другому элементу.
Если заглянуть в официальную документацию, то там указана причина. Данный псевдоэлемент привносит собою свойство isolation со значением isolate.

Для лучшего понимания рекомендую самостоятельно посмотреть на предустановленные стили для каждого из псевдоэлементов. Вы обнаружите, что каждый из них содержит и другие свойства, а также какие-то из них могут быть унаследованы свыше, благодаря предустановленному значению inherit.

Кастомные анимации
После того, как мы разобрались с принципом работы View Transition API, можем приступать к построению собственной анимации. Воспользуемся ранее изученными псевдоэлементами и директивой @keyframes:
::view-transition-old(root) {
animation-name: slide-out-to-left;
}
::view-transition-new(root) {
animation-name: slide-in-from-right;
}
@keyframes slide-in-from-right {
from {
translate: 100vw 0;
}
}
@keyframes slide-out-to-left {
to {
translate: -100vw 0;
}
}
В скобках, для простоты примера, мы использовали root. Как вы помните, root - это <html> элемент. ::view-transition-old собирается на основе элемента старой страницы, а ::view-transition-new - новой. Позже разберём, как вместо корневого элемента можно использовать любой другой.
Стоит отметить, что данный код нужно также вставить на обе страницы, иначе вместо отсутствующего new или old будет вставлен стандартный браузерный и вы получите совершенно неожиданный результат:
Анимация отдельных элементов страницы
Для того, чтобы у вас появилась возможность отделить элемент от root для собственной анимации, вам необходимо дать ему имя при помощи свойства view-transition-name:
#title {
view-transition-name: title;
}
Теперь вы можете использовать это имя во ::view-transition-old и ::view-transition-new и анимировать элемент:
::view-transition-old(title) {
animation-name: my-animation-1;
}
::view-transition-new(title) {
animation-name: my-animation-2;
}
@keyframes my-animation-1 {
to {
translate: 100vw 0;
}
}
@keyframes my-animation-2 {
from {
translate: -100vw 0;
}
}
Теперь внутри ::view-transition появился ещё один ::view-transition-group, который был создан для title:

Важно понимать, что одно и то же имя на одной странице можно дать только одному элементу, и оно также должно присутствовать на другой странице и тоже в единственном экземпляре, иначе будет ошибка и некорректно работающая анимация:

Продвинутая анимация, учимся определять направление пагинации
Для того, чтобы можно было менять тип анимации в зависимости от различных обстоятельств, был создан псевдокласс :active-view-transition-type. Он может быть применён только к <html> элементу:
html:active-view-transition-type(backwards) {
&::view-transition-old(content) {
animation-name: slide-out-to-right;
}
&::view-transition-new(content) {
animation-name: slide-in-from-left;
}
}
html:active-view-transition-type(forwards) {
&::view-transition-old(content) {
animation-name: slide-out-to-left;
}
&::view-transition-new(content) {
animation-name: slide-in-from-right;
}
}
В качестве параметра, как вы уже и заметили, мы передаём ему какую-либо строку (название типа анимации). В нашем случае это backwards и forwards. Первый определяет анимацию при переключении на предыдущие страницы, второй на последующие.
Далее, для вызова нужного блока, необходимо передать эту самую строку из JS на каждой из страниц таким кодом:
event.viewTransition.types.add('forwards');
Вроде бы всё понятно, но откуда берётся этот event?
Существует 2 браузерных события: pageswap и pagereveal. Именно они дают нам Set, при помощи которого можно добавлять или удалять какие-либо строки (названия типов) и тем самым управлять анимацией:
window.addEventListener("pageswap", async (event) => {
event.viewTransition.types.add("forwards");
});
window.addEventListener("pagereveal", async (event) => {
event.viewTransition.types.add("forwards");
});
Перед тем, как переходить к более сложномым примерам, давайте разберём, как они работают:
pageswapвызывается на старой странице ровно перед тем, как старый HTML код будет заменён на новый.pagerevealвызывается на новой странице перед отрисовкой первого кадра при первой загрузке документа. Работает также при загрузке из bfcache и prerender из Speculation Rules API.
Таким образом, pageswap мы можем использовать, чтобы добавить необходимые названия типов на старой странице, а pagereveal - на новой. Ниже представлен пример кода, который определяет направление пагинации и передаёт его при помощи ранее упомянутого Set:
window.addEventListener("pageswap", async (event) => {
const transitionType = determineTransitionType(
event.activation.from,
event.activation.entry
);
event.viewTransition.types.add(transitionType);
});
window.addEventListener("pagereveal", async (event) => {
const transitionType = determineTransitionType(
navigation.activation.from,
navigation.activation.entry
);
event.viewTransition.types.add(transitionType);
});
Для того, чтобы определить, откуда и куда произошёл переход, мы воспользовались встроенным в событие свойством activation, а также глобальным объектом navigation.
Объявление функции
determineTransitionTypeя опустил, чтобы не нагружать пример, но если хотите, то могу скинуть в комментариях.
Дополнительно стоит добавить проверки на поддержку View Transition и Navigation API. На момент написания статьи они поддерживаются не во всех браузерах:
window.addEventListener("pageswap", async (event) => {
if (event.viewTransition) {
const transitionType = determineTransitionType(
event.activation.from,
event.activation.entry
);
event.viewTransition.types.add(transitionType);
if (!window.navigation) {
localStorage.setItem("transitionType", transitionType);
}
}
});
window.addEventListener("pagereveal", async (event) => {
if (event.viewTransition) {
let transitionType;
if (!window.navigation) {
transitionType = localStorage.getItem("transitionType");
} else {
transitionType = determineTransitionType(
navigation.activation.from,
navigation.activation.entry
);
}
event.viewTransition.types.add(transitionType);
}
});
Переходы между разными состояниями DOM
Как вы знаете, добавление, перемещение, удаление узлов DOM сложно поддаются анимации CSS, чаще всего это какие-то обходные способы над основными возможностями браузера.
В решении этой проблемы нам также может помочь View Transitions API.
Для того, чтобы запустить переход, мы должны вызвать метод startViewTransition() с колбэком, внутри которого будут произведены все необходимые изменения DOM:
const onClick = (event) => {
document.startViewTransition(() => {
updateTheDOMSomehow();
});
};
Механизм работы метода аналогичен мультистраничным переходам: в момент вызова идёт фиксация старого состояния страницы, затем, на основе переданного колбэка, происходит вычисление нового и, наконец, плавный переход между этими состояниями:
Если вы хотите передать названия типов перехода также, как мы делали это ранее с многостраничным вариантом, то вам необходимо использовать другой синтаксис этого метода:
document.startViewTransition({
update: updateTheDOMSomehow,
types: ["move", "forwards"],
});
Также вы можете записать результат вызова этого метода в переменную, чтобы получить доступ к дополнительным фичам данного API:
const doTransition = async () => {
const transition = document.startViewTransition({
update: updateTheDOMSomehow,
types: ["move", "forwards"],
});
await transition.updateCallbackDone;
console.log("update callback done");
await transition.ready;
console.log("ready");
await transition.finished;
console.log("transition finished");
};
Свойства:updateCallbackDone - возвращает промис, который переходит в состояние fulfilled, когда функция переданная в startViewTransition завершила своё выполнение.
ready - возвращает промис, который переходит в состояние fulfilled, когда дерево псевдоэлементов уже собрано и переход готов начаться.
finished - возвращает промис, который переходит в fulfilled, когда анимация полностью завершена и пользователь видит обновлённый интерфейс.
Методы:skipTransition() - пропускает анимацию перехода, но не пропускает обновления DOM.
Упрощаем код
У вас могут возникнуть ситуации, при которых невозможно заранее определить количество элементов на странице. Представьте себе список, который в любой момент может быть изменён. Количество его элементов могут быть уменьшено или, наоборот, увеличено.
Чтобы анимация была у каждого такого элемента, вам необходимо будет соорудить что-то вроде этого:
#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }
…
#card20 { view-transition-name: card20; }
::view-transition-group(card1),
::view-transition-group(card2),
::view-transition-group(card3),
::view-transition-group(card4),
…
::view-transition-group(card20) {
animation-timing-function: var(--bounce);
}
Громоздко, не правда ли? Для решения этой проблемы было создано свойство view-transition-class:
#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }
#card5 { view-transition-name: card5; }
…
#card20 { view-transition-name: card20; }
#cards-wrapper > div {
view-transition-class: card;
}
html::view-transition-group(.card) {
animation-timing-function: var(--bounce);
}
Оно позволяет объединить сразу несколько элементов под одним классом и использовать его при обращении в псевдоэлементах View Transition API, вместо view-transition-name.
Но, заметьте! view-transition-class не освобождает нас от необходимости указывать view-transition-name, иначе пары ::view-transition-old() и ::view-transition-new() так и не будут созданы.
Блокировка рендеринга
Перед тем как начать переход между двумя страницами или состояниями DOM нужно убедиться, что:
Важные скрипты прогрузились и были запущены
Важные стили прогрузились и были применены
Нужный HTML был отрисован
Стили по-умолчанию блокируют рендеринг до их полной загрузки, за исключением стилей, которые были динамически добавлены при помощи JS кода.
Для блокировки рендеринга при невыполненном JS и динамически добавленном CSS, используется атрибут blocking="render":
<head>
<script async src="script.js" blocking="render"></script>
</head>
<head>
<script>
const style = document.createElement('style');
style.setAttribute('blocking', 'render');
style.textContent = `
.some-element {
width: 280px;
`;
const head = document.head;
head.appendChild(style);
</script>
</head>
Для блокировки рендеринга при неотрисованном HTML используется тег <link>. Ему нужно передать атрибут blocking="render", а также id элемента в href через символ #:
<html lang="en">
<head>
<link rel="expect" href="#some-content" blocking="render" />
</head>
<body>
<div id="some-content">
Lorem ipsum dolor sit amet
</div>
</body>
</html>