Inicio / Artículos

Transiciones de vista sensibles a la dirección en React con addTransitionType

Aprenda cómo las etiquetas addTransitionType marcan una transición de React junto con su causa, para que ViewTransition, CSS y los manejadores de eventos puedan seleccionar la dirección del deslizamiento sin necesidad de un estado adicional.

Editor: REACTAPP.TOP13 min de lectura2900 palabras

Fuentes: addTransitionType in React 19.3: Direction-Aware Animations Without Direction State

Imagínese un proceso de pago en varios pasos con botones de Siguiente y Atrás. Ambos actualizan el mismo valor step, pero las animaciones deberían ser diferentes: al avanzar, el nuevo panel debe aparecer desde la derecha, y al retroceder, desde la izquierda. Dado que el cambio de estado se ve idéntico en ambos casos, el componente no cuenta con una forma integrada de saber qué animación reproducir, y la solución tradicional es utilizar un valor direction separado del cual se espera que siga siendo preciso cuando finalmente se ejecute la animación.

React 19.3 añade addTransitionType para resolver este problema de manera limpia. Se llama desde startTransition para registrar el motivo de una actualización, y <ViewTransition> utiliza ese motivo para elegir una animación. Después de leer esto, conocerá los tres lugares donde se puede utilizar un tipo de transición, cómo se combinan los tipos, qué problemas evitar y cómo lograr el mismo efecto en versiones anteriores de React. Se basa en el propio componente <ViewTransition>; si ese componente le resulta nuevo, comience primero con la referencia de React sobre él.

Toda la API en dos importaciones

La función se encuentra en el paquete principal react, junto a startTransition.

import { startTransition, addTransitionType } from 'react';

Cada manejador de navegación abre una Transición, la etiqueta con una cadena y luego realiza la actualización normal de estado. La etiqueta puede ser cualquier cadena que desee; aquí los dos manejadores utilizan next y back.

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

El contrato es sencillo. addTransitionType(type) acepta una única cadena y no tiene valor de retorno. Debe ejecutarse dentro del callback pasado a startTransition. Si se llama en cualquier otro lugar, la versión de desarrollo de React registra la advertencia “addTransitionType solo puede llamarse dentro de un callback de startTransition()” y descarta el tipo. Se permiten múltiples llamadas, y React reúne todos los tipos en la misma Transición.

Lo interesante no es la función sino sus lectores. Hay tres de ellos.

Consumidor 1: valores de objeto en las propiedades ViewTransition

Cada propiedad de disparador de <ViewTransition> (enter, exit, update, share y default) puede aceptar un objeto en lugar de solo un nombre de clase. Dicho objeto asocia los tipos de transición con un nombre de clase, con "auto" o con "none". La clave default funciona como solución de respaldo cuando no está presente ninguno de los tipos enumerados.

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

Un asistente completo impulsado por tipos

El siguiente asistente pone esto en práctica. Observe key={step} en el límite: cada cambio de paso desmonta el panel anterior y monta uno nuevo, por lo que React lo trata como una salida seguida de una entrada, y el tipo activo determina la dirección de ambas partes.

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

Un ayudante go único recibe el delta del paso y el nombre del tipo, de modo que ambos botones comparten una misma ruta de código.

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>
    </>
  );
}

Los estilos de layout posicionan el panel de forma absoluta dentro de un escenario recortado, lo que evita que las capturas salientes e entrantes se desplacen unas a otras.

.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);
}

Las reglas de animación apuntan a los pseudo-elementos de transición de vista por clase. Las capturas antiguas reciben los keyframes de salida to-left o to-right, las nuevas capturas reciben from-right o from-left, y los cuatro keyframes simplemente se desplazan por una anchura completa mientras se desvanecen.

::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; } }

Mire lo que el componente no contiene: no hay ninguna dirección almacenada en ningún lugar. La dirección pertenece a la actualización, se adjunta en el instante en que se programa dicha actualización y viaja junto con ella a través del planificador de React. Cuando un usuario presiona Atrás dos veces rápidamente mientras aún se está reproduciendo la primera diapositiva, React agrupa el trabajo en cola y la segunda animación lee los tipos recopilados para esa segunda transición. No existe ningún directionRef que pueda desincronizarse.

Cómo resuelve React los tipos coincidentes

React aplica estas reglas, en este orden:

  • Cuando coinciden más de un tipo, los nombres de clase correspondientes se concatenan y todos se aplican.
  • Cuando no coincide nada, React recurre a la entrada default; si no existe, ese disparador no se animará.
  • Cuando cualquier entrada que coincida es "none", esta tiene prioridad y el límite omite por completo la animación.
  • Consumer 2: tipos de transición de vista nativos en CSS

    React también pasa cada tipo de transición al navegador como un tipo de transición de vista nativo. Como resultado, puedes delimitar las animaciones desde una hoja de estilos utilizando la pseudo-clase :active-view-transition-type() y dejar las propiedades del componente intactas.

    El primer paso es asignar al elemento una view-transition-class. La instantánea hereda esa clase, y React no la sobrescribe.

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

    Luego escribe reglas que solo se apliquen mientras se ejecute una transición de un tipo determinado. La sintaxis de anidamiento mantiene juntos ambos pseudo-elementos para una misma dirección.

    /* 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; }
    }
    

    El mismo asistente, estilizado únicamente con CSS

    Esta versión del asistente no pasa ninguna propiedad enter ni exit. El límite es simple, y la hoja de estilos toma todas las decisiones en función del tipo activo.

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

    El código del componente es, por lo demás, idéntico al de la primera demostración.

    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>
        </>
      );
    }
    

    Los estilos del panel ahora incluyen view-transition-class: panel, que es lo que utilizan los selectores a continuación.

    .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;
    }
    

    Las reglas de ámbito de tipo se encuentran en :root, y los keyframes no han cambiado con respecto a antes.

    /* 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; } }
    

    Elija este estilo cuando toda una página debe reaccionar a un único motivo, por ejemplo, un enrutador que etiqueta las navegaciones como navigation-forward o navigation-back. Prefiera los atributos de objeto cuando un límite específico necesita su propio mapeo. Mantenga la línea view-transition-class: si se especifica (*) en lugar de (.panel), la regla también se aplica al snapshot raíz, y toda la página se desliza junto con el panel.

    Consumidor 3: devoluciones de evento

    Las funciones de callback onEnter, onExit, onUpdate y onShare reciben los tipos recopilados como su segundo parámetro. Eso permite que el código de animación imperativa también tome decisiones según la causa. En este fragmento, el elemento que entra comienza fuera de la pantalla a la izquierda en el caso de back y a la derecha en otros casos; se anima con la API de Animaciones Web, y devuelve una función de limpieza que cancela la animación si React la interrumpe.

    <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();
      }}
    >
    

    Es importante devolver la función de limpieza: sin ella, una transición interrumpida rápidamente podría dejar una animación en ejecución vinculada a un estado que ya no existe.

    Aplicar varios tipos en una sola transición

    Dado que React mantiene cada tipo añadido dentro de la Transición, se puede superponer una consideración independiente a la dirección. En este carrusel, una casilla de verificación “rápido” agrega un segundo tipo llamado quick. Su clase solo acorta la duración, y ambas clases terminan en el snapshot juntas.

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

    La función auxiliar go siempre agrega la dirección y, de forma condicional, agrega quick. Cada objeto prop asocia quick con una clase del mismo nombre, por lo que la clase de dirección correspondiente y la clase quick se unen.

    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>
        </>
      );
    }
    

    El diseño sigue siendo el mismo que antes, con un escenario más alto para el emoji grande.

    .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); }
    

    Las reglas de dirección establecen una animación deliberadamente lenta de 700 ms, y la regla quick solo sobrescribe animation-duration, dejando intactas las keyframes y la curva de aceleración.

    ::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; } }
    

    Esta composición representa la verdadera ventaja del sistema de tipos: los aspectos ortogonales como la dirección, la velocidad o el énfasis pueden expresarse como etiquetas separadas en lugar de una matriz cada vez mayor de nombres de clases combinados.

    El enfoque basado en estado que reemplaza

    Antes de esta API, la dirección solía almacenarse en el estado del componente, lo cual no es su lugar adecuado. La configuración típica añade un segundo elemento de estado:

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

    Cada manejador escribe la dirección y luego el paso, y el resultado de renderizado convierte la dirección en un nombre de clase:

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

    Ese patrón tiene tres debilidades:

    • El estado adicional provoca una nueva renderización para información que en realidad no forma parte de la interfaz de usuario.
    • Dos clics rápidos en direcciones opuestas pueden hacer que direction describa una actualización diferente a la que se está animando.
  • Cuando step cambia por alguna otra razón, como un enlace profundo o un botón de reinicio, la dirección restante se aplica a una actualización que no tiene nada que ver con ella.
  • Las bibliotecas de animación tienden a ocultar el mismo problema detrás de un parámetro. El valor custom={direction} de Framer Motion, pasado a las variantes, sigue siendo un valor almacenado, solo que con un nombre diferente.

    Por qué es importante trasladar la causa a la actualización

    Con addTransitionType, la causa forma parte de la actualización y no del estado. El cambio es pequeño, pero las consecuencias son significativas: la causa no puede volverse obsoleta, no puede afectar actualizaciones no relacionadas y no implica costo alguno en el renderizado. También crea un canal común para las bibliotecas. Un router puede etiquetar las navegaciones hacia adelante y hacia atrás una sola vez, y cada <ViewTransition> en cualquier página puede reaccionar a esas etiquetas. La documentación de React indica que espera que los routers y otras bibliotecas converjan en nombres de tipo compartidos.

    El equipo de React también ha señalado que los tipos no están limitados a las animaciones y que se planean más usos para la causa de una transición. El mecanismo subyacente, que consiste en adjuntar metadatos a una transición y leerlos en el momento del commit, es lo suficientemente general como para permitirlo.

    Riesgos a conocer antes de lanzar

    • El tipo solo tiene validez dentro de startTransition. Fuera de él, React descarta el tipo con una advertencia que solo aparece en modo desarrollo, y como la actualización es urgente, de todos modos nada se anima.
    • Los tipos se eliminan después de cada commit. Si la transición muestra primero un fallback de <Suspense>, ese commit con el fallback conserva tus tipos, mientras que la posterior muestra del contenido real no los mantiene. Para animaciones sensibles a la dirección más allá de un límite de carga, vuelve a agregar el tipo cuando el contenido esté listo, o diseña el cambio de fallback al contenido de modo que no dependa de la dirección.
    • "none" siempre prevalece. Si alguna entrada que coincide se resuelve en "none", ese disparador queda desactivado incluso cuando otro tipo que coincide proporcionó una clase.
  • Un objeto sin la clave default solo anima los tipos que se especifican, por lo que las transiciones sin etiqueta que atraviesen ese límite no se animarán. Esto suele ser intencional, pero es fácil pasarlo por alto.
  • El soporte de los navegadores difiere entre las dos vías. Dado que React establece la clase por sí mismo en el snapshot, el enfoque con propiedades de objeto funciona en cualquier navegador que tenga las Clases de Transición de Vista: Chrome 125, Safari 18.2 y Firefox 144. El estilo mediante :active-view-transition-type() requiere Chrome 125, Safari 18.2 o Firefox 147 como mínimo. Consulte los datos de compatibilidad de MDN para obtener información actualizada.
  • Los nombres de tipo son cadenas globales que llegan al navegador sin cambios. Añada un prefijo, por ejemplo wizard-next, cuando dos funcionalidades en una misma página puedan utilizar ambos next.
  • Opción de respaldo para React 19.2 y versiones anteriores

    Las versiones antiguas de React no pueden utilizar addTransitionType, pero la API del navegador sigue pudiendo manejar el tipo. Dejas de contar con la coordinación de las transiciones por parte de React, pero no con la información sobre la dirección. Debes llamar tú mismo a document.startViewTransition, pasar los valores de types y reutilizar el CSS del segundo consumidor sin realizar cambios. Necesitas flushSync para que la actualización de estado se aplique de forma síncrona dentro del callback de actualización del navegador:

    import { flushSync } from 'react-dom';
    

    El helper también funciona de manera adecuada en casos deficientes: cuando el navegador no cuenta con startViewTransition, simplemente actualiza el estado sin animación.

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

    Tras actualizar a 19.3, el cuerpo de go vuelve a ser la versión corta startTransition de la primera sección, y la hoja de estilo no cambia en absoluto.

    Puntos clave

    • Trate la dirección de la animación como metadatos sobre una actualización, no como estado del componente; addTransitionType es la herramienta para ello.
    • Consuma los tipos a través de las propiedades del objeto para mapeos por límite, mediante :active-view-transition-type() para reglas a nivel de página, o a través de callbacks de eventos para animaciones imperativas.
    • Use tipos independientes, como dirección más velocidad, en lugar de inventar nombres de clases combinados.
    • Tenga presente los aspectos críticos: los elementos en estado Suspense pierden sus tipos, "none" sobrescribe todo, y los nombres de tipo comparten un espacio de nombres global.
    • Para conocer todos los detalles de la API, consulte la referencia de addTransitionType y la sección de personalización basada en tipos de los documentos de ViewTransition.

    Lecturas relacionadas