Главная / Статьи

Переходы видимости с учётом направления в React с использованием addTransitionType

Узнайте, как с помощью тегов addTransitionType задать тип перехода в React Transition вместе с причиной его возникновения, чтобы ViewTransition, CSS и обработчики событий могли определить направление слайдирования без дополнительного состояния.

Издатель: REACTAPP.TOP13 мин чтения2900 слов

Источники: addTransitionType in React 19.3: Direction-Aware Animations Without Direction State

Представьте себе процесс оформления заказа с несколькими шагами, в котором есть кнопки «Далее» и «Назад». Обе они обновляют одно и то же значение step, но анимации должны отличаться: при переходе вперед новая панель должна появляться справа, а при переходе назад — слева. Поскольку изменение состояния выглядит одинаково в обоих случаях, у компонента нет встроенного способа определить, какая анимация должна воспроизводиться; классическим решением является использование отдельного значения direction, от которого ожидается, что оно будет корректным к моменту запуска анимации.

В React 19.3 была добавлена функция addTransitionType, которая позволяет решить эту проблему эффективно. Её вызывают внутри startTransition для указания причины обновления, и компонент <ViewTransition> использует эту причину для выбора анимации. Прочитав это, вы узнаете, в трех каких местах может использоваться тип перехода, как сочетаются разные типы, на какие подводные камни стоит обратить внимание и как добиться того же эффекта в более старых версиях React. Это расширение основано на самом компоненте <ViewTransition>; если этот компонент вам нов, сначала ознакомьтесь с соответствующей документацией React.

Вся API — в двух импортах

Эта функция находится в основном пакете react, рядом с функцией startTransition.

import { startTransition, addTransitionType } from 'react';

Каждый обработчик навигации открывает объект Transition, присваивает ему метку в виде строки, а затем выполняет обычную обновление состояния. Меткой может быть любая строка по вашему выбору; здесь два обработчика используют next и back.

function goNext() {
  startTransition(() => {
    addTransitionType('next');   // any string
    setStep(s => s + 1);
  });
}function goBack() {
  startTransition(() => {
    addTransitionType('back');
    setStep(s => s - 1);
  });
}

Контракт этой функции прост: addTransitionType(type) принимает одну строку и не возвращает никакого значения. Она должна выполняться внутри обратного вызова, переданного функции startTransition. Если её вызвать где-либо ещё, разработочная версия React выведет предупреждение «addTransitionType можно вызывать только внутри обратного вызова startTransition()» и проигнорирует указанный тип. Множественные вызовы допускаются, и React объединяет все типы в один объект Transition.

Интересна не сама функция, а те, кто её использует. Их три.

Потребитель 1: значения объектов в свойствах ViewTransition

Каждый триггер <ViewTransition> (enter, exit, update, share и default) может принимать объект вместо простого имени класса. Этот объект соотносит типы переходов с именем класса, значением "auto" или "none". Значение default используется в качестве альтернативы, когда ни один из указанных типов отсутствует.

<ViewTransition
  enter={{ next: 'from-right', back: 'from-left', default: 'auto' }}
  exit={{ next: 'to-left', back: 'to-right', default: 'auto' }}
>
  <Step />
</ViewTransition>

Полный мастер, управляемый типами

Следующий мастер демонстрирует это на практике. Обратите внимание на key={step} у границы: при каждом изменении шага снимается старая панель и устанавливается новая, поэтому React рассматривает это как выход и вход одновременно, причем активный тип определяет направление действий для обеих частей.

import { ViewTransition, useState, startTransition, addTransitionType } from 'react';

Один помощник go принимает изменение шага и имя типа, поэтому обе кнопки используют один и тот же путь кода.

const STEPS = ['Cart', 'Address', 'Payment', 'Review'];export default function App() {
  const [step, setStep] = useState(0);  function go(delta, type) {
    startTransition(() => {
      addTransitionType(type);
      setStep(s => s + delta);
    });
  }  return (
    <>
      <div className="bar">
        <button disabled={step === 0} onClick={() => go(-1, 'back')}>← Back</button>
        <span>Step {step + 1} of {STEPS.length}</span>
        <button disabled={step === STEPS.length - 1} onClick={() => go(1, 'next')}>Next →</button>
      </div>      <div className="stage">
        <ViewTransition
          key={step}
          enter={{ next: 'from-right', back: 'from-left' }}
          exit={{ next: 'to-left', back: 'to-right' }}
        >
          <section className="panel">{STEPS[step]}</section>
        </ViewTransition>
      </div>
    </>
  );
}

Стили макета размещают панель абсолютно внутри ограниченной области сцены, что предотвращает взаимное смещение выходящих и входящих кадров.

.bar { display: flex; align-items: center; gap: 12px; }
.stage { position: relative; height: 90px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
.panel {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  font: 600 22px system-ui;
  background: light-dark(#e0e7ff, #1e1b4b);
}

Правила анимации применяются к псевдоэлементам перехода видов по классу. Старые кадры получают ключевые кадры выхода to-left или to-right, новые кадры — from-right или from-left, причем все четыре ключевых кадра просто смещаются на полную ширину при одновременном затемнении.

::view-transition-old(.to-left)    { animation: 260ms ease-in  both to-left; }
::view-transition-old(.to-right)   { animation: 260ms ease-in  both to-right; }
::view-transition-new(.from-right) { animation: 300ms ease-out both from-right; }
::view-transition-new(.from-left)  { animation: 300ms ease-out both from-left; }@keyframes to-left    { to   { transform: translateX(-100%); opacity: 0; } }
@keyframes to-right   { to   { transform: translateX(100%);  opacity: 0; } }
@keyframes from-right { from { transform: translateX(100%);  opacity: 0; } }
@keyframes from-left  { from { transform: translateX(-100%); opacity: 0; } }

Посмотрите, чего нет в компоненте: нигде не хранится информация о направлении. Направление связано с процессом обновления, присваивается в момент планирования обновления и передаётся вместе с ним через систему планирования React. Когда пользователь быстро дважды нажимает кнопку «Назад», пока всё ещё воспроизводится первый слайд, React объединяет все задания в одну группу, и вторая анимация использует типы, собранные для этого перехода. Не существует никакого directionRef, который мог бы привести к разнесению временных меток.

Как React определяет соответствующие типы

React применяет следующие правила в этом порядке:

  • Если совпадает более одного типа, соответствующие имена классов объединяются, и применяются все из них.
  • Если ничего не совпадает, React переходит к значению default; если его нет, анимация для данного события не запускается.
  • Когда соответствующая запись равна "none", она имеет приоритет, и анимация на границе полностью пропускается.
  • Consumer 2: типы переходов видимости в CSS

    React также передаёт каждый тип перехода браузеру в качестве встроенного типа перехода видимости. Благодаря этому вы можете ограничить действие анимаций из стилишита с помощью псевдокласса :active-view-transition-type(), не затрагивая параметры компонента.

    Первым шагом является присвоение элементу атрибута view-transition-class. Этот класс наследуется снимком состояния, и React не перезаписывает его.

    /* give the element a class the snapshot inherits — React leaves it alone */
    .panel { view-transition-class: panel; }
    

    Затем пишутся правила, которые применяются только во время выполнения перехода определённого типа. Синтаксис вложенности позволяет объединить оба псевдоэлемента для одного направления.

    /* applies only while a transition tagged 'next' is running */
    :root:active-view-transition-type(next) {
      &::view-transition-old(.panel) { animation: 220ms ease-in both to-left; }
      &::view-transition-new(.panel) { animation: 280ms ease-out both from-right; }
    }
    :root:active-view-transition-type(back) {
      &::view-transition-old(.panel) { animation: 220ms ease-in both to-right; }
      &::view-transition-new(.panel) { animation: 280ms ease-out both from-left; }
    }
    

    Тот же мастер-инструмент, оформленный исключительно с помощью CSS

    В этой версии магического инструмента совсем не передаются параметры enter или exit. Границы остаются простыми, а стилишит создаёт все правила на основе активного типа.

    import { ViewTransition, useState, startTransition, addTransitionType } from 'react';
    

    Код компонента в остальном идентичен первому примеру.

    const STEPS = ['Cart', 'Address', 'Payment', 'Review'];export default function App() {
      const [step, setStep] = useState(0);  function go(delta, type) {
        startTransition(() => {
          addTransitionType(type);
          setStep(s => s + delta);
        });
      }  return (
        <>
          <div className="bar">
            <button disabled={step === 0} onClick={() => go(-1, 'back')}>← Back</button>
            <span>Step {step + 1} of {STEPS.length}</span>
            <button disabled={step === STEPS.length - 1} onClick={() => go(1, 'next')}>Next →</button>
          </div>
          <div className="stage">
            <ViewTransition key={step}>
              <section className="panel">{STEPS[step]}</section>
            </ViewTransition>
          </div>
        </>
      );
    }
    

    Стили панели теперь включают view-transition-class: panel, к чему и привязываются указанные ниже селекторы.

    .bar { display: flex; align-items: center; gap: 12px; }
    .stage { position: relative; height: 90px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
    .panel {
      position: absolute; inset: 0;
      display: grid; place-items: center;
      font: 600 22px system-ui;
      background: light-dark(#fce7f3, #4a1d3a);
      view-transition-class: panel;
    }
    

    Правила, связанные с типом, находятся в элементе :root, а ключевые кадры остались без изменений по сравнению с предыдущей версией.

    /* The type React forwarded to the browser scopes the whole rule */
    :root:active-view-transition-type(next) {
      &::view-transition-old(.panel) { animation: 260ms ease-in  both to-left; }
      &::view-transition-new(.panel) { animation: 300ms ease-out both from-right; }
    }
    :root:active-view-transition-type(back) {
      &::view-transition-old(.panel) { animation: 260ms ease-in  both to-right; }
      &::view-transition-new(.panel) { animation: 300ms ease-out both from-left; }
    }@keyframes to-left    { to   { transform: translateX(-100%); opacity: 0; } }
    @keyframes to-right   { to   { transform: translateX(100%);  opacity: 0; } }
    @keyframes from-right { from { transform: translateX(100%);  opacity: 0; } }
    @keyframes from-left  { from { transform: translateX(-100%); opacity: 0; } }
    

    Выбирайте этот стиль, когда вся страница должна реагировать на одну причину, например, когда маршрутизатор помечает навигации как navigation-forward или navigation-back. Лучше использовать свойства объекта, если какая-то конкретная граница требует собственной привязки. Оставьте строку view-transition-class: если указать (*) вместо (.panel), правило также будет применяться к корневому снимку, и вся страница будет скроллиться вместе с панелью.

    Consumer 3: обратные вызовы событий

    Функции-обратные вызовы onEnter, onExit, onUpdate и onShare получают собранные типы в качестве своего второго параметра. Это позволяет коду анимации также принимать решения в зависимости от причины события. В этом фрагменте элемент, входящий в экран, находится с левой стороны за пределами экрана при нажатии на back, а в остальных случаях — с правой стороны; он анимируется с помощью Web Animations API, и возвращается функция для очистки, которая отменяет анимацию, если React прерывает её.

    <ViewTransition
      onEnter={(instance, types) => {
        const from = types.includes('back') ? '-100%' : '100%';
        const anim = instance.new.animate(
          [{ transform: `translateX(${from})` }, { transform: 'none' }],
          { duration: 300, easing: 'ease-out' }
        );
        return () => anim.cancel();
      }}
    >
    

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

    Размещение нескольких типов в одном переходе

    Поскольку React хранит каждый тип внутри объекта Transition, можно добавлять дополнительные параметры поверх направления анимации. В этом карусели поле с чекбоксом «быстро» добавляет второй тип с именем quick. Его класс лишь укорачивает продолжительность анимации, и оба класса оказываются в одном объекте snapshot.

    import { ViewTransition, useState, startTransition, addTransitionType } from 'react';
    

    Вспомогательная функция go всегда добавляет параметр направления и условно добавляет quick. Каждый объект prop соответствует quick классу с таким же именем, поэтому класс направления и класс quick объединяются вместе.

    const SLIDES = ['🍎', '🍋', '🥝', '🍇'];export default function App() {
      const [i, setI] = useState(0);
      const [quick, setQuick] = useState(false);  function go(delta, type) {
        startTransition(() => {
          addTransitionType(type);
          if (quick) addTransitionType('quick');
          setI(n => (n + delta + SLIDES.length) % SLIDES.length);
        });
      }  return (
        <>
          <div className="bar">
            <button onClick={() => go(-1, 'back')}>←</button>
            <button onClick={() => go(1, 'next')}>→</button>
            <label><input type="checkbox" checked={quick} onChange={e => setQuick(e.target.checked)} /> quick</label>
          </div>
          <div className="stage">
            <ViewTransition
              key={i}
              enter={{ next: 'from-right', back: 'from-left', quick: 'quick' }}
              exit={{ next: 'to-left', back: 'to-right', quick: 'quick' }}
            >
              <div className="slide">{SLIDES[i]}</div>
            </ViewTransition>
          </div>
        </>
      );
    }
    

    Концепция разметки такая же, как и раньше, только с более высокой площадкой для крупных эмодзи.

    .bar { display: flex; align-items: center; gap: 12px; }
    .stage { position: relative; height: 110px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
    .slide { position: absolute; inset: 0; display: grid; place-items: center; font-size: 56px; background: light-dark(#fef9c3, #3f3a12); }
    

    Правила направления задают намеренно медленную анимацию продолжительностью 700 мс, а правило quick заменяет только параметр animation-duration, оставляя ключевые кадры и функции плавности без изменений.

    ::view-transition-old(.to-left)    { animation: 700ms ease-in  both to-left; }
    ::view-transition-old(.to-right)   { animation: 700ms ease-in  both to-right; }
    ::view-transition-new(.from-right) { animation: 700ms ease-out both from-right; }
    ::view-transition-new(.from-left)  { animation: 700ms ease-out both from-left; }/* second class on the same snapshot: only the duration changes */
    ::view-transition-old(.quick), ::view-transition-new(.quick) { animation-duration: 120ms; }@keyframes to-left    { to   { transform: translateX(-100%); opacity: 0; } }
    @keyframes to-right   { to   { transform: translateX(100%);  opacity: 0; } }
    @keyframes from-right { from { transform: translateX(100%);  opacity: 0; } }
    @keyframes from-left  { from { transform: translateX(-100%); opacity: 0; } }
    

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

    Подход, заменяемый им

    До появления этого API направление обычно хранилось в состоянии компонента, что является неправильным способом его хранения. Типичная настройка включает добавление ещё одного элемента состояния:

    const [step, setStep] = useState(0);
    const [direction, setDirection] = useState('next');
    

    Каждый обработчик записывает направление, а затем шаг, после чего результат отрисовки преобразует направление в имя класса:

    function goNext() {
      setDirection('next');
      setStep(s => s + 1);
    }// then, in render:
    <div className={`panel enter-${direction}`} key={step}>…</div>
    

    У этого подхода три недостатка:

    • Дополнительное состояние вызывает перерисовку информации, которая на самом деле не является частью интерфейса.
    • Два быстрых клика в противоположных направлениях могут привести к тому, что переменная direction будет отражать другое обновление, чем то, которое анимируется.
  • Когда значение step меняется по какой-либо другой причине, например из-за глубокой ссылки или кнопки перезапуска, оставшееся направление применяется к обновлению, с которым оно не связано.
  • Библиотеки анимаций обычно скрывают ту же проблему за специальным свойством. Параметр custom={direction} из Framer Motion, передаваемый вариантам, по-прежнему является сохраняемым значением, только с более подходящим названием.

    Почему важно переместить причину изменений в само обновление

    С помощью addTransitionType причина перехода является частью обновления, а не состояния. Изменение незначительно, но последствия значимы: причина не может устареть, не может повлиять на нерелевантные обновления и не требует дополнительной обработки при отрисовке. Кроме того, это создаёт общий канал связи для библиотек. Маршрутизатор может один раз присвоить метки для переходов вперёд и назад, и каждый элемент <ViewTransition> на любой странице может реагировать на эти метки. В документации React говорится, что ожидается, что маршрутизаторы и другие библиотеки будут использовать совместные имена типов.

    Команда React также отметила, что типы не ограничиваются анимациями, и планируется использование причины перехода в более широком контексте. Основной механизм — привязка метаданных к переходу и их чтение во время сохранения изменений — достаточно универсален, чтобы поддерживать такое использование.

    Опасности, о которых следует знать перед выпуском

    • Этот тип учитывается только внутри функции startTransition. Вне неё React игнорирует тип с предупреждением, которое отображается только в режиме разработки; к тому же из-за срочности обновления ничего не анимируется.
    • Типы сбрасываются после каждого коммита. Если в процессе перехода сначала отображается замена <Suspense>, то коммит с этой заменой сохраняет ваши типы, тогда как последующее отображение реального содержимого их не сохраняет. Для анимаций, учитывающих направление перемещения через границу загрузки, добавьте тип снова, когда содержимое будет готово, или спроектируйте процесс замены замены на содержимое так, чтобы он не зависел от направления.
    • "none" всегда имеет приоритет. Если какой-либо соответствующий вариант приводит к значению "none", этот триггер отключается, даже если другой соответствующий тип предоставил класс.
  • Объект без ключа default анимирует только для тех типов, которые в нем указаны, поэтому переходы без меток, проходящие через этот границы, не будут анимироваться. Это часто делается намеренно, но легко упустить из виду.
  • Поддержка браузерами отличается в зависимости от выбранного пути. Поскольку React сам устанавливает класс в снимке, подход с объектом-свойством работает в любом браузере, поддерживающем View Transition Classes: Chrome 125, Safari 18.2 и Firefox 144. Стилизация с помощью :active-view-transition-type() требует более новых версий — Chrome 125, Safari 18.2 или Firefox 147. Для получения актуальной информации ознакомьтесь с данными о совместимости на MDN.
  • Имена типов — это глобальные строки, которые поступают в браузер без изменений. Добавляйте к ним префикс, например wizard-next, если два элемента на одной странице могут использовать ключ next.
  • Альтернатива для React 19.2 и более старых версий

    Старые версии React не могут использовать addTransitionType, но API браузера всё ещё позволяет указывать тип перехода. Вы теряете возможность координации переходов со стороны React, но сохраняете информацию о направлении изменений. Самостоятельно вызывайте document.startViewTransition, передавайте параметр types и снова используйте CSS из второго компонента без изменений. Для синхронного применения обновления состояния внутри обратной связи браузера требуется функция flushSync:

    import { flushSync } from 'react-dom';
    

    Помощник также корректно справляется с отсутствием поддержки: если у браузера нет функции startViewTransition, он просто обновляет состояние без анимации.

    function go(delta, type) {
      if (!document.startViewTransition) {
        setStep(s => s + delta);
        return;
      }
      document.startViewTransition({
        update: () => flushSync(() => setStep(s => s + delta)),
        types: [type],
      });
    }
    

    После обновления до версии 19.3 тело функции go снова становится краткой версией startTransition из первого раздела, а стильный лист совершенно не меняется.

    Основные выводы

    • Рассматривайте направление анимации как метаданные об обновлении, а не как состояние компонента; для этого используется функция addTransitionType.
    • Используйте различные типы через свойства объектов для настройки параметров в пределах отдельных границ, через :active-view-transition-type() для правил, действующих на всей странице, или через обратные вызовы событий для императивных анимаций.
    • Складывайте независимые типы, такие как направление и скорость, вместо того чтобы создавать комбинированные имена классов.
    • Не забывайте о важных моментах: элементы Suspense теряют свои типы, значение "none" переопределяет всё, а имена типов находятся в глобальном пространстве имён.
    • Подробную информацию об API смотрите в документации к addTransitionType и в разделе настройки на основе типов в документации ViewTransition.