Element-scoped View Transitions, ультимативный способ запустить анимацию?

• dom

Вступление

На текущий момент у нас много разных способов сделать анимацию:

  • CSS-анимации (transition/animation)
  • Element.prototype.animate
  • JS-анимации
  • View Transitions
  • Наверняка что-то ещё забыл

Под View Transitions обычно понимают анимации документа, которые используются в навигации между страницами. Однако не ограничиваемся только этим! С помощью document.startViewTransition анимацию можно запустить в произвольный момент, а не только на переход.

При желании, можно нафантазировать кучу вариантов использования, от открытия меню до растягивания блока. Но это может разбиться о минусы document.startViewTransition:

  • Анимация только одна в один момент времени
  • Элементы не интерактивны во время анимации
  • Проблемы с порядком слоёв (если анимируемый элемент под другим, например)

В связи с этим стоит посмотреть на недавнюю новинку – Element.prototype.startViewTransition. Решает ли это проблемы и какие нюансы остаются?

Element startViewTransition

Если вы знакомы с document.startViewTransition, то будет очень похоже:

node.startViewTransition(() => {
    doDOMChanges();
});

Запускаем функцию, ждём старта колбека, делаем изменения (синхронно или асинхронно), и всё, у нас есть анимация между двумя состояниями. Анимации можно настроить с помощью CSS по логике, аналогичной document.startViewTransition. Какие же отличия?

  1. Отрисовка анимации происходит в элементе, а не поверх страницы. Это значит, что порядок слоёв не будет нарушен
  2. Можно делать несколько анимаций одновременно (в разных элементах)
  3. Остальная часть страницы остаётся интерактивна

Что ещё? Элемент автоматически получает следующие свойства:

  1. overflow: clip, в результате чего анимации не вылезают за пределы элемента
  2. view-transition-name: root, тем самым элемент анимируется сам без дополнительных свойств
  3. view-transition-scope: all, чтобы ограничить снятие снепшотов дочерними элементами (и, как следствие, их анимацию)
  4. view-transition-group: contain, которое решает некоторые проблемы вложенных view-transition

Зачем вообще использовать такие анимации?

В каких случаях хорошо себя показывают View Transition? Помимо навигации, конечно же.

Во-первых, в случае, если элемент переключает своё состояние, и в этих состояниях заметно разная вёрстка. Эта вёрстка может в принципе плохо жить рядом друг с другом.

Более того, часто во фреймворках нужно делать дополнительные действия, чтобы компоненты существовали рядом с друг другом. Приведу пример в Реакте, чтобы было понятнее:

function Component() {
    const [toggled, setToggled] = useState(false);
 
    return (
        <div>
            {toggled ? <Expanded /> : <Collapsed />}
        </div>
    );
}

Что делать, если на время анимации нужно показать оба компонента, и <Expanded />, и <Collapsed /> (один исчезает, второй появляется)? В случае Реакта писать подобное руками будет неприятно, и на помощь придут сторонние библиотеки (говорят, <AnimatePresence /> из motion может помочь, например).

В случае Svelte есть встроенный механизм отложенного размонтирования, когда компонент остаётся в DOM на время, пока длится его анимация ухода. В других фреймворках есть свои варианты решения. Но если используется startViewTransition, то эта проблема решается сама!

Возвращаясь к вариантам использования View Transition, вторым приходит на ум анимация раскрытия элемента. Или, другими словами, когда элемент был в одном месте DOM, а потом оказался в другом. Например, если открывается попап из маленького превью. Когда-то способ реализации описывал в своей статье Пол Льюис и называл их FLIP анимациями (first, last, invert, play). Оригинальную статью найти не могу, но есть статья в его личном блоге.

View Transitions делают подход FLIP по большей части ненужным и хорошо решают подобные анимации, упрощая их реализацию.

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

Не забываем про нюансы

Конечно, перед использованием апи не забываем проверять, что оно поддержано в браузерах (касается как document.startViewTransition, так и element...). В случае View Transitions есть простой фолбек – можно мгновенно перейти в конечное состояние без анимации:

if (!node.startViewTransition) {
    doDOMChanges();
    return;
}
node.startViewTransition(() => {
    doDOMChanges();
});

Также не стоит забывать и о том, что пользователи могут не хотеть анимации, и стоит проверять prefers-reduced-motion: reduce.

С чем, как мне кажется, View Transitions не помогут, так это с переходом фокуса. Часто при открытии попапов / панелей может понадобиться перенос фокуса в нужный элемент (в частности для доступности). В случае, если позвать фокус слишком рано, то фокус может перейти, и даже от ненужного скролла можно избавиться, а вот рамки скринридеров могут застрять в неправильной позиции. Надо исследовать подробнее, теоретизирую.

Текущее состояние View Transition в Реакте

Бонус. Посмотрим, как работать с View Transitions в Реакте. Оставлю за скобками react-router, который предлагает поддержку из коробки – там всё-таки речь про навигацию, а с навигацией всё более-менее понятно.

Как запустить node.startViewTransition руками? Тут есть небольшая проблема из-за того, что отрисовка асинхронная, и колбека в явном виде нет. Что ж, заставим Реакт рисовать синхронно:

import { flushSync } from 'react-dom';
 
...
 
node.startViewTransition(() => {
    flushSync(() => {
        setToggled(true);
    });
});

flushSync выполняет применение изменений Реакта в DOM синхронно, что, в теории, может приводить к “длинным” кадрам и к ощутимым тормозам для пользователя.

Какие ещё пути есть? В экспериментальной / canary ветке Реакта есть новый компонент <ViewTransition />, который может попасть в релиз 19.3.0. Использование примерно такое:

import { ViewTransition, useState, startTransition } from 'react';
 
function Item() {
    return (
        <ViewTransition>
            <Child />
        </ViewTransition>
    );
}
 
export function Component() {
  const [showItem, setShowItem] = useState(false);
  return (
    <>
      <button
        onClick={() => {
          startTransition(() => {
            setShowItem((prev) => !prev);
          });
        }}>
        {showItem ? '➖' : '➕'}
      </button>
 
      {showItem ? <Item /> : null}
    </>
  );
}

Минусов 2. Переходы работают только в случае использования useTransition, useDeferredValue или через <Suspense>. Просто установка состояния из useState не заработает. А второй минус в том, что компонент <ViewTransition> работает только через document.startViewTransition, “поэлементного” View Transition на текущий момент нет.

Итого

Пока реализация только в одном браузере, но остальные подтянутся. Подождать несколько лет – и можно потихоньку затаскивать в продакшен)

К тому времени подтянутся и фреймворки, и мы научимся нормально всё это использовать. Беспокоит только общая сложность современных апи, а также их раздробленность. Те же View Transition состоят из множества подпунктов, которые релизятся годами. view-transition-group поддержан только в одном браузере. Нужно серьёзно закопаться, чтобы понять, где есть SPA переходы, а где поддержаны только MPA. Будут ли этим заморачиваться разработчики условно через год? Думаю, не все.

Могут помочь llm, но будут ли они прям полностью корректно писать код под устаревшие на тот момент браузеры, которые никто не проверит – тоже вопрос.

Обсудить в Telegram

Почитать ещё посты

  • Псевдоэлементы теперь настоящие
  • WebTransport всех победит?
  • Origin API
  • Отслеживание поддержки фич
  • Navigation API реализован во всех браузерах