Accueil / Articles

Transitions d’affichage sensibles à la direction dans React avec addTransitionType

Apprenez comment addTransitionType étiquette une transition React avec sa cause, afin que ViewTransition, le CSS et les gestionnaires d’événements puissent choisir la direction du défilement sans état supplémentaire.

Éditeur : REACTAPP.TOP13 min de lecture2900 mots

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

Imaginez un processus de paiement en plusieurs étapes avec des boutons « Suivant » et « Retour ». Ces deux boutons mettent à jour la même valeur step, mais les animations devraient être différentes : avancer devrait faire apparaître le nouveau panneau depuis la droite, tandis que revenir en arrière devrait le faire apparaître depuis la gauche. Comme le changement d’état semble identique dans les deux cas, le composant ne dispose d’aucun moyen intégré pour savoir quelle animation afficher ; la solution classique consiste à utiliser une valeur direction distincte, en espérant qu’elle reste exacte au moment où l’animation s’exécute finalement.

React 19.3 ajoute addTransitionType pour résoudre ce problème de manière propre. Vous l’appeliez à l’intérieur de startTransition pour enregistrer la raison d’une mise à jour, et <ViewTransition> utilise cette raison pour choisir une animation. Après avoir lu ceci, vous connaîtrez les trois endroits où un type de transition peut être utilisé, la manière dont les types se combinent, les pièges à éviter, ainsi que la façon d’obtenir le même effet sur des versions plus anciennes de React. Cela s’appuie sur la composante <ViewTransition> elle-même ; si cette composante vous est inconnue, commencez par consulter la référence de React à son sujet.

Toute l’API en deux imports

La fonction se trouve dans le paquet principal react, à côté de startTransition.

import { startTransition, addTransitionType } from 'react';

Chaque gestionnaire de navigation ouvre une Transition, la labelle avec une chaîne de caractères, puis effectue la mise à jour d’état habituelle. La étiquette peut être n’importe quelle chaîne de caractères ; ici, les deux gestionnaires utilisent next et back.

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

Le contrat est simple. addTransitionType(type) accepte une seule chaîne de caractères et ne renvoie aucune valeur. Il doit être exécuté à l’intérieur du callback passé à startTransition. Si vous l’appellez ailleurs, la version de développement de React affiche l’avertissement « addTransitionType ne peut être appelé qu’à l’intérieur d’un callback startTransition() » et ignore ce type. Plusieurs appels sont autorisés, et React rassemble tous les types sur la même Transition.

La partie intéressante n’est pas la fonction elle-même, mais ses utilisateurs. Il y en a trois.

Utilisateur 1 : valeurs d’objet sur les propriétés ViewTransition

Chaque propriété déclencheuse de <ViewTransition> (enter, exit, update, share et default) peut accepter un objet plutôt qu’un simple nom de classe. Cet objet associe les types de transition à un nom de classe, à "auto" ou à "none". La clé default sert de solution de repli lorsque aucun des types listés n’est présent.

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

Un assistant complet piloté par les types

L’assistant suivant met cela en pratique. Remarquez la valeur key={step} sur la frontière : chaque changement d’étape démonte l’ancien panneau et monte un nouveau, de sorte que React le traite comme une sortie suivie d’une entrée, et le type actif détermine la direction des deux parties.

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

Un seul outil go reçoit la différence d’étape ainsi que le nom du type, ce qui permet aux deux boutons de partager un même chemin de code.

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

Les styles de mise en page positionnent le panneau de manière absolue à l’intérieur d’une scène délimitée, ce qui empêche les captures sortantes et entrantes de se repousser mutuellement.

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

Les règles d’animation ciblent les pseudo-éléments de transition d’affichage par classe. Les anciennes captures reçoivent les keyframes de sortie to-left ou to-right, les nouvelles captures reçoivent from-right ou from-left, et les quatre keyframes provoquent simplement un déplacement sur toute la largeur tout en s’estompant.

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

Regardez ce que le composant ne contient pas : il n’y a aucune direction stockée nulle part. La direction appartient à la mise à jour, elle est attachée au moment où celle-ci est planifiée et se déplace avec elle via le planificateur de React. Lorsqu’un utilisateur appuie deux fois rapidement sur « Retour » tandis que la première diapositive est encore en cours d’affichage, React regroupe les tâches en file d’attente et la deuxième animation lit les types collectés pour cette seconde transition. Aucun directionRef n’existe qui pourrait provoquer un décalage.

Comment React résout les types correspondants

React applique ces règles, dans cet ordre :

  • Lorsqu’un ou plusieurs types correspondent, les noms de classe correspondants sont concaténés et tous sont appliqués.
  • Lorsqu’aucun type ne correspond, React recourt à l’entrée default ; en l’absence de celle-ci, cette action ne provoque aucune animation.
  • Lorsque la valeur correspondante est "none", elle prend le pas de priorité et l’animation est complètement omise.
  • Consommateur 2 : types de transition d’affichage natifs en CSS

    React transmet également chaque type de transition au navigateur sous forme de type de transition d’affichage natif. Ainsi, vous pouvez limiter les animations provenant d’un fichier de style en utilisant la pseudo-classe :active-view-transition-type() sans toucher aux propriétés du composant.

    La première étape consiste à attribuer à l’élément une classe view-transition-class. Le snapshot hérite de cette classe, et React ne la remplace pas.

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

    Ensuite, écrivez des règles qui ne s’appliquent que tant qu’une transition d’un type donné est en cours. La syntaxe de nesting permet de regrouper les deux pseudo-éléments pour une même direction.

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

    Le même assistant, stylisé uniquement avec CSS

    Cette version du wizard ne transmet absolument aucune propriété enter ou exit. La limite est vide, et le fichier de style prend toutes les décisions en fonction du type actif.

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

    Le code de la composante est sinon identique au premier exemple.

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

    Les styles du panneau incluent désormais view-transition-class: panel, c’est ce à quoi se rattachent les sélecteurs ci-dessous.

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

    Les règles définies au niveau du type se trouvent sur :root, et les keyframes restent inchangés par rapport à avant.

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

    Choisissez ce style lorsque toute une page doit réagir à une seule cause, par exemple un routeur qui étiquette les navigations en navigation-forward ou navigation-back. Préférez les propriétés d’objet lorsque une frontière spécifique a besoin de sa propre correspondance. Conservez la ligne view-transition-class : si vous ciblez (*) au lieu de (.panel), la règle s’applique également à l’aperçu racine, et toute la page glisse ensemble avec le panneau.

    Consommateur 3 : appels de fonction en réponse à des événements

    Les fonctions de rappel onEnter, onExit, onUpdate et onShare reçoivent les types collectés comme deuxième paramètre. Cela permet également aux codes d’animation impératifs de prendre en compte la cause de l’événement. Dans cet extrait, l’élément qui entre commence hors écran à gauche pour l’action back et à droite dans les autres cas ; il est animé à l’aide de la Web Animations API, et une fonction de nettoyage est retournée pour annuler l’animation si React l’interrompt.

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

    Il est important de retourner cette fonction de nettoyage : sans elle, une transition interrompue rapidement pourrait laisser une animation en cours attachée à un état qui n’existe plus.

    Superposition de plusieurs types sur une même transition

    Puisque React conserve chaque type ajouté à l’intérieur de Transition, vous pouvez superposer une considération indépendante à la direction. Dans ce carrousel, une case à cocher « quick » ajoute un deuxième type nommé quick. Sa classe ne fait que raccourcir la durée, et les deux classes finissent par apparaître ensemble dans le snapshot.

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

    L’aide-mémoire go ajoute toujours la direction et, de manière conditionnelle, quick. Chaque objet prop associe quick à une classe du même nom, de sorte que la classe de direction correspondante et la classe quick sont combinées.

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

    Le principe de mise en page est identique à celui précédent, avec une zone plus haute pour l’emoji de grande taille.

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

    Les règles de direction définissent une animation intentionnellement lente de 700 ms, et la règle quick ne modifie que animation-duration, laissant les keyframes et les fonctions d’ajustement inchangés.

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

    Cette composition représente la véritable valeur ajoutée du système de types : des aspects orthogonaux tels que la direction, la vitesse ou l’accentuation peuvent être exprimés sous forme d’étiquettes distinctes, plutôt que dans une matrice croissante de noms de classes combinés.

    L’approche basée sur l’état qu’elle remplace

    Auparavant, la direction était généralement stockée dans l’état du composant, ce qui n’est pas son emplacement approprié. La configuration typique ajoute un deuxième élément d’état :

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

    Chaque gestionnaire écrit d’abord la direction, puis l’étape, et le résultat de rendu intègre cette direction dans un nom de classe :

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

    Cette approche présente trois faiblesses :

    • L’état supplémentaire provoque un re-rendu pour des informations qui ne font pas réellement partie de l’interface utilisateur.
    • Deux clics rapides dans des directions opposées peuvent faire en sorte que la variable direction décrit une mise à jour différente de celle qui est animée.
  • Lorsque step change pour une autre raison, comme un lien profond ou un bouton de redémarrage, la direction restante est appliquée à une mise à jour qui n’a rien à voir avec elle.
  • Les bibliothèques d’animation ont tendance à cacher le même problème derrière une propriété. La valeur custom={direction} de Framer Motion transmise aux variants reste une valeur stockée, simplement mieux nommée.

    Pourquoi il est important de déplacer la cause vers la mise à jour

    Avec addTransitionType, la cause fait partie de la mise à jour plutôt que de l’état. Le changement est mineur, mais les conséquences sont importantes : la cause ne peut pas devenir obsolète, ne peut pas affecter des mises à jour non liées, et n’entraîne aucune opération de rendu supplémentaire. Cela crée également un canal commun pour les bibliothèques. Un routeur peut étiqueter les navigations vers l’avant et vers l’arrière une seule fois, et chaque <ViewTransition> sur n’importe quelle page peut réagir à ces étiquettes. La documentation React indique qu’elle attend que les routeurs et d’autres bibliothèques convergent vers des noms de types partagés.

    L’équipe React a également précisé que les types ne sont pas limités aux animations et qu’il est prévu d’en trouver davantage d’utilisations pour la cause d’une transition. Le mécanisme sous-jacent, qui consiste à associer des métadonnées à une Transition et à les lire au moment de la mise en œuvre, est suffisamment général pour le permettre.

    Péripéties à connaître avant la mise en production

    • Le type n’a d’effet que à l’intérieur de startTransition. En dehors de cette fonction, React supprime le type en affichant un avertissement visible uniquement en mode développement, et comme la mise à jour est urgente, rien n’est animé de toute façon.
    • Les types sont effacés après chaque commit. Si la Transition affiche d’abord un fallback <Suspense>, ce commit de fallback conserve vos types, tandis que la présentation ultérieure du contenu réel ne les conserve pas. Pour des animations sensibles à la direction au-delà d’une limite de chargement, ajoutez à nouveau le type lorsque le contenu est prêt, ou concevez l’échange fallback/contenu de manière à ce qu’il ne dépende pas de la direction.
    • "none" l’emporte toujours. Si une entrée correspondante se résout en "none", ce déclencheur est désactivé même si un autre type correspondant a fourni une classe.
  • Un objet sans clé default ne s’animera que pour les types qu’il indique, de sorte que les transitions non étiquetées qui traversent cette limite ne s’animeraient pas. Cela est souvent intentionnel, mais facile à oublier.
  • La compatibilité avec les navigateurs diffère selon ces deux approches. Comme React définit lui-même la classe sur le snapshot, l’approche basée sur les propriétés d’objet fonctionne dans n’importe quel navigateur disposant des View Transition Classes : Chrome 125, Safari 18.2 et Firefox 144. L’application de styles via :active-view-transition-type() exige quant à elle Chrome 125, Safari 18.2 ou Firefox 147. Consultez les données de compatibilité de MDN pour obtenir des informations à jour.
  • Les noms de types sont des chaînes globales qui parviennent au navigateur sans modification. Préfixez-les, par exemple wizard-next, lorsque deux fonctionnalités sur une même page pourraient utiliser next.
  • Une solution de secours pour React 19.2 et versions antérieures

    Les versions anciennes de React ne peuvent pas utiliser addTransitionType, mais l’API du navigateur peut toujours gérer le type. Vous renoncez à la coordination des transitions par React, mais pas aux informations relatives à la direction. Appelez vous-même document.startViewTransition, passez les paramètres types, et réutilisez le CSS du deuxième consommateur sans modification. Vous avez besoin de flushSync afin que la mise à jour d’état soit appliquée de manière synchrone dans le callback de mise à jour du navigateur :

    import { flushSync } from 'react-dom';
    

    L’aide-mémoire fonctionne également de manière fiable en cas de problème : lorsque le navigateur ne dispose pas de startViewTransition, il met simplement à jour l’état sans animation.

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

    Après la mise à jour vers 19.3, le corps de la fonction go revient à la version courte startTransition présentée dans la première section, et le fichier CSS ne change pas du tout.

    Points clés

    • Considérez la direction de l’animation comme des métadonnées relatives à une mise à jour, et non comme un état de composant ; addTransitionType est l’outil adapté à cela.
    • Utilisez les types via des propriétés d’objet pour des mappages par bordure, via :active-view-transition-type() pour des règles applicables à toute la page, ou via des callbacks d’événement pour des animations impératives.
    • Empilez des types indépendants, tels que la direction et la vitesse, plutôt que d’inventer des noms de classes combinés.
    • N’oubliez pas les points importants : les éléments en mode Suspense perdent leurs types, "none" prend le pas sur tout, et les noms de types partagent un espace de noms global.
    • Pour en savoir plus sur l’API, consultez la référence de addTransitionType ainsi que la section sur la personnalisation basée sur les types dans les documents ViewTransition.