Comprendre et reproduire les animations de transitions Vue.js en CSS et JavaScript

En tant que traducteur principal de la documentation officielle française de Vue.js, lors de la traduction de la page Transitions d'entrée, de sortie et de liste, j'ai été bluffé par la simplicité de gestion des animations de transition proposé par Vue.js. J'ai mis un peu de temps à comprendre exactement comment ça pouvait fonctionner sous le capot et je vous propose de faire ce cheminement de compréhension ensemble à travers cet article.

Cela vous permettra :

  • De reproduire une fonction permettant de réaliser des animations de transition simple.
  • De comprendre comment fonctionne le système d'animation de transition de Vue.js.
Transitions Vue.js
Source : https://fr.vuejs.org/

À la main

Commençons par les bases et évinçons l'idée de transition pour le moment. De façon simple, pour changer l'état d'un composant il suffit d'une classe de changement d'état. Je vais pouvoir ainsi pouvoir indiquer dans ma feuille de style (que nous nommerons dans cet article « la CSS ») sur un élément en indiquant qu'il est dans l'état par défaut en display: none; et dire avec la classe d'état .is-displayed que l'élément est affiché via la propriété de style display: block;. Vous pourrez trouver plus de détails sur cette notion dans cet article.

Voyons cela simplement avec un élément de classe .simple-example--message qui possède son état initial et un état .is-displayed.

HTML :

<div class="simple-example">
    <!-- On créé un bouton -->
    <button class="simple-example--button">Afficher/Masquer</button>

    <!-- qui permet d'afficher ce message. -->
    <div class="simple-example--message">Tu me vois !</div>
</div>

CSS :

/* L'état initial. */
.simple-example--message {
    display: none;
}

/* L'état affiché. */
.simple-example--message.is-displayed {
    display: block;
}

JavaScript :

// On récupère le bouton et le message.
var button = document.getElementsByClassName('simple-example--button')[0],
    message = document.getElementsByClassName('simple-example--message')[0];

// Quand on clique sur le bouton
button.addEventListener('click', function () {

    // on affiche/masque le message.
    message.classList.toggle('is-displayed');
});

Résultat :

Tu me vois !

Changement d'état avec transition

Un changement d'état est rapide et abrupte, pour rendre cela plus élégant, nous pouvons accompagner cette disparition d'un effet de transition. Pour transformer ce simple changement d'état en une animation de transition nous allons utiliser la propriété opacity à la place de display. Nous verrons plus loin pourquoi nous faisons ce changement et allons voir de suite ce que cela peut donner.

HTML :

<!-- Note : identique à l'exemple précédent. -->
<div class="simple-example">
    <button class="simple-example--button">Afficher/Masquer</button>
    <div class="simple-example--message">Tu me vois !</div>
</div>

CSS :

.simple-example--message {

    /* Nous allons permettre de changer */
    /* un élément invisible, */
    opacity: 0;

    /* en 1 seconde, */
    transition: opacity 1s;
}
.simple-example--message.is-displayed {

    /* en un élément visible. */
    opacity: 1;
}

JavaScript :

// Note : identique à l'exemple précédent.
var button = document.getElementsByClassName('simple-example--button')[0],
    message = document.getElementsByClassName('simple-example--message')[0];

button.addEventListener('click', function () {
    message.classList.toggle('is-displayed');
});

Résultat :

Tu me vois !

Les défauts de cette approche

Règles CSS imcompatibles

Vous remarquerez cependant que pour permettre cela nous avons dû faire des choix. Effectivement, nous avons dû nous passer de la propriété display pour permettre à la transition de marcher. Un élément non affiché avant démarrage de la transition l'empêche de fonctionner. Cela change quelque peut notre approche puisque vous pouvez remarquer dans ce cas que l'espace sous le bouton existe déjà vu que nous le cachons simplement ici.

Mettons en évidence ce problème en le testant dans cet exemple.

HTML :

<!-- Note : identique à l'exemple précédent. -->
<div class="simple-example">
    <button class="simple-example--button">Afficher/Masquer</button>
    <div class="simple-example--message">Tu me vois !</div>
</div>

CSS :

.simple-example--message {

    /* Ajout de la propriété `display`. */
    display: none;
    opacity: 0;
    transition: opacity 1s;
}
.simple-example--message.is-displayed {

    /* Ajout de la propriété `display`. */
    display: block;
    opacity: 1;
}

JavaScript :

// Note : identique à l'exemple précédent.
var button = document.getElementsByClassName('simple-example--button')[0],
    message = document.getElementsByClassName('simple-example--message')[0];

button.addEventListener('click', function () {
    message.classList.toggle('is-displayed');
});

Résultat :

Tu me vois !

Ouverture et fermeture identique

Quand on effectue une transition, nous pouvons décrire deux états.

  • La transition qui accompagne l'élément de son état standard à son état .is-display. Nous appelons cela une transition entrante (ou transition d'ouverture).
  • La transition qui accompagne l'élément de son état .is-display à son état standard. Nous appelons cela une transition sortante (ou transition de fermeture).

Il faut savoir dès lors que notre utilisation actuelle dans la CSS rend impossible de faire une transition entrante différente de la transition sortante. Nous entendons par différente le fait de faire autre chose que l'animation inverse. Il va falloir gérer les transitions ailleurs que dans la classe principale pour résoudre ce point.

Nous allons donc améliorer le code JavaScript pour répondre à ces problématiques !

Gestion de la méthode de transition

La première solution apportée par Vue.js est de gérer la durée de transition, les étapes de transition et la courbe de transition dans une classe séparée. Il va d'ailleurs y avoir deux classes. Une qui va gérer les instructions pour la transition entrante (de l'état standard à l'état alternatif) et une qui va gérer les instructions pour la transition sortante (de l'état alternatif à l'état standard).

  • C'est la classe …-enter-active qui gérera la transition de l'état standard à l'état .is-displayed.
  • C'est la classe …-leave-active qui gérera la transition de l'état .is-displayed à l'état standard.

Il suffit dans ce modèle de remplacer les par le nom que vous souhaitez donner à votre transition. Appelons la display. Voyons cela avec le code suivant.

HTML :

<!-- Note : similaire à l'exemple précédent avec le nom des classes différent. -->
<div class="transition-example">
    <button class="transition-example--button">Afficher/Masquer</button>
    <div class="transition-example--message">Tu me vois !</div>
</div>

CSS :

/* Définition de l'état standard. */
.transition-example--message {
    opacity: 0;
    transform: translateX(0)
}

/* Définition de l'état `.is-displayed`. */
.transition-example--message.is-displayed {
    opacity: 1;
    transform: translateX(200px)
}

/* Instruction de transition entrante. */
.transition-example--message.display-enter-active {

    /* Nous plaçons les informations de transition ici maintenant. */
    transition: opacity 1s, transform 1s;
}

/* Instruction de transition sortante. */
.transition-example--message.display-leave-active {

    /* Nous décidons de masquer le message plus lentement qu'il ne s'affiche. */
    transition: opacity 4s, transform 4s;
}

JavaScript :

var button = document.getElementsByClassName('transition-example--button')[0],
    message = document.getElementsByClassName('transition-example--message')[0];

button.addEventListener('click', function () {

    // On vérifie l'état de notre composant.
    // Renvoi `true` s'il est ouvert.
    // Renvoi `false` s'il est fermé.
    var isDisplayed = message.classList.contains('is-displayed');

    // Remise à zéro des transitions.
    message.classList.remove('display-enter-active');
    message.classList.remove('display-leave-active');

    // On applique la transition souhaitée.
    // On inverse `isDisplayed` pour gérer la
    // transition entrante d'abord,
    if (!isDisplayed) {

        // en appliquant `…-enter-active` pour la transition entrante
        message.classList.add('display-enter-active');

        // et en appliquant l'état `.is-displayed`.
        message.classList.add('is-displayed');

    // Puis la transition sortante ensuite,
    } else {

        // en appliquant `…-leave-active` pour la transition sortante
        message.classList.add('display-leave-active');

        // et en appliquant l'état standard.
        message.classList.remove('is-displayed');
    }
});

Résultat :

Tu me vois !

État de début et de fin de transition

La seconde solution apportée par Vue.js pour gérer l'existence ou non de l'objet, ou le fait qu'il possède des propriétés qui rendent les transitions incompatibles (comme display) est de mettre en place un état de début et un état de fin de transition respectivement :

  • en démarrage et fin de transition entrante et
  • en démarrage et fin de transition sortante.

Ouverture

La transition entrante, à laquelle sera associée tout du long l'état …-enter-active se gère avec les classes suivantes :

  • La classe …-enter vous permettra de définir quelles sont les propriétés de démarrage, juste avant que l'on enclenche la transition entrante.
  • La classe …-enter-to vous permettra de définir quelles sont les propriétés à atteindre en fin de transition entrante.

Ainsi l'animation se jouera des valeurs de l'état …-enter jusqu'aux valeurs de l'état …-enter-to en suivant les instructions de transition dans …-enter-active.

Fermeture

La transition sortante, à laquelle sera associée tout du long l'état …-leave-active se gère avec les classes suivantes :

  • La classe …-leave vous permettra de définir quelles sont les propriétés de démarrage, juste avant que l'on enclenche la transition sortante.
  • La classe …-leave-to vous permettra de définir quelles sont les propriétés à atteindre en fin de transition sortante.

Ainsi l'animation se jouera des valeurs de l'état …-leave jusqu'aux valeurs de l'état …-leave-to en suivant les instructions de transition dans …-enter-active.

Exemple

Il suffit dans ce modèle de remplacer les par le nom que vous souhaitez donner à votre transition. Voyons cela avec le code suivant :

HTML :

<!-- Note : identique à l'exemple précédent. -->
<div class="transition-example">
    <button class="transition-example--button">Afficher/Masquer</button>
    <div class="transition-example--message">Tu me vois !</div>
</div>

CSS :

/* Définition de l'état standard. */
.transition-example--message {
    display: none;
}

/* Définition de l'état `.is-displayed`. */
.transition-example--message.is-displayed {
    display: block;
}


/* Ici nous définissons l'état de l'élément au début */
/* de la transition entrante. */
.transition-example--message.display-enter {
    transform: translateY(0)
}

/* Ici nous définissons l'état de l'élément à la fin */
/* de la transition entrante. */
.transition-example--message.display-enter-to {
    transform: translateY(50px)
}


/* Ici nous définissons l'état de l'élément au début */
/* de la transition sortante. */
.transition-example--message.display-leave {
    transform: translateX(200px)
}

/* Ici nous définissons l'état de l'élément à la fin */
/* de la transition sortante. */
.transition-example--message.display-leave-to {
    transform: translateY(0)
}


/* Nous définissons ici les animations qui vont opérer */
/* lors de la transition entrante. */
.transition-example--message.display-enter-active {
    transition: transform 1s;
}
/* Nous définissons ici les animations qui vont opérer */
/* lors de la transition sortante. */
.transition-example--message.display-leave-active {
    transition: transform 4s;
}

JavaScript :

var button = document.getElementsByClassName("transition-example--button")[0],
    message = document.getElementsByClassName("transition-example--message")[0];

button.addEventListener("click", function() {
    var isDisplayed = message.classList.contains("is-displayed");

    // Lors de la transition entrante,
    if (!isDisplayed) {

        // on place l'état `.is-diplayed`,
        message.classList.add("is-displayed");

        // on retire la classe de fin de la transition sortante et
        message.classList.remove("display-leave-to");

        // on place la classe de début de la transition entrante.
        message.classList.add("display-enter");

        // Puis une boucle plus tard,
        setTimeout(function() {

            // on explique comment ces propriétés vont varier
            // en appliquant la classe de transition entrante active.
            message.classList.add("display-enter-active");

            // Puis une boucle plus tard,
            setTimeout(function() {

                // on déclenche la transition en intervertissant les valeurs
                // de début vers les valeurs de fin de transition entrante.
                message.classList.remove("display-enter");
                message.classList.add("display-enter-to");

                // Puis à la fin des 1 seconde,
                setTimeout(function() {

                    // On retire la classe de transition entrante active.
                    message.classList.remove("display-enter-active");
                }, 1000);
            }, 0);
        }, 0);

    // Lors de la transition sortante.
    } else {

        // on retire la classe de fin de la transition entrante,
        message.classList.remove("display-enter-to");

        // On place la classe de début de la transition sortante.
        message.classList.add("display-leave");

        // Puis une boucle plus tard,
        setTimeout(function() {

            // on explique comment ces propriétés vont varier
            // en appliquant la classe de transition sortante active.
            message.classList.add("display-leave-active");

            // Puis une boucle plus tard,
            setTimeout(function() {

                // on déclenche la transition en intervertissant les valeurs
                // de début vers les valeurs de fin de transition sortante.
                message.classList.remove("display-leave");
                message.classList.add("display-leave-to");

                // Puis à la fin des 4 secondes,
                setTimeout(function() {

                    // on retire la classe de transition entrante active et
                    message.classList.remove("display-leave-active");

                    // on retire l'état `.is-diplayed`.
                    message.classList.remove("is-displayed");
                }, 4000);
            }, 0);
        }, 0);
    }
});

Résultat :

Tu me vois !

Aller plus loin

Dans notre exemple, le système se base sur l'état .is-displayed pour savoir s'il doit enclencher une transition entrante ou une transition sortante. Mais il y a d'autre moyen de prendre cette décision, notamment la présence de …-enter ou …-leave-to sur l'élément par exemple. Il est également possible, comme cela peut-être le cas pour Vue.js de se baser sur la présence ou non de l'élément dans le DOM avant transition.

La cible, la transition et l'état

Nous allons maintenant automatiser notre système dans une fonction que nous nommerons —et là je sens que je vais vous étonner— transition.

HTML :

<!-- Note : similaire à l'exemple précédent avec le nom des classes différent. -->
<div class="animation-example">
    <button class="animation-example--button">Afficher/Masquer</button>
    <div class="animation-example--message">Tu me vois !</div>
</div>

CSS :

/* Définition de l'état standard et alternatif. */
.animation-example--message {
    display: none;
}
.animation-example--message.is-displayed {
    display: block;
}

/* On créé une animation. */
@keyframes bounce-in {
  0% {
    transform: scale(0);
    opacicy: 0;
  }
  50% {
    transform: scale(1.5);
    opacicy: 0.8;
  }
  100% {
    transform: scale(1);
    opacicy: 1;
  }
}

/* On applique l'animation pour transition entrante. */
.animation-example--message.animate-enter-active {
    animation: bounce-in 1s;
}

/* On applique l'animation pour transition sortante. */
.animation-example--message.animate-leave-active {
    animation: bounce-in 4s reverse;
}

JavaScript :

var button, message;

// On déplace tout dans une fonction avec 3 paramètres qui représentent la cible, le nom de transition et le nom de l'état.

/**
 * Permet d'exécuter une transition entre deux états pour un élément spécifique.
 * @param  {HTMLElement} target     - L'élément HTML qui doit être animé.
 * @param  {string}      transition - Le nom de la transition remplaçant `…` pour les classes `…-enter`, `…-leave`, etc.
 * @param  {string}      state      - L'état placé au début d'une transition entrante et retiré en fin d'une transition sortante.
 */
function transition(target, transition, state) {
    // On test s'il n'existe pas d'état de sortie.
    var hasNoState = !target.classList.contains(state);

    // Transition entrante s'il n'existe pas d'état.
    if (hasNoState) {

        // On gère le nom des classes d'état via le paramètre `state`.
        target.classList.add(state);

        // On gère les noms des classes de transition via le paramètre `transition`.
        target.classList.remove(transition + "-leave-to");

        // On gère l'élément HTML ciblé via le paramètre `target`.
        target.classList.add(transition + "-enter");

        setTimeout(function() {
            target.classList.add(transition + "-enter-active");

            setTimeout(function() {
                target.classList.remove(transition + "-enter");
                target.classList.add(transition + "-enter-to");

                setTimeout(function() {
                    target.classList.remove(transition + "-enter-active");
                }, 1000);
            }, 0);
        }, 0);

    // Transition sortante s'il existe un état.
    } else {
        target.classList.remove(transition + "-enter-to");
        target.classList.add(transition + "-leave");

        setTimeout(function() {
            target.classList.add(transition + "-leave-active");

            setTimeout(function() {
                target.classList.remove(transition + "-leave");
                target.classList.add(transition + "-leave-to");

                setTimeout(function() {
                    target.classList.remove(transition + "-leave-active");
                    target.classList.remove(state);
                }, 4000);
            }, 0);
        }, 0);
    }
}

button = document.getElementsByClassName("animation-example--button")[0];
message = document.getElementsByClassName("animation-example--message")[0];

button.addEventListener("click", function() {

    // On applique les transitions en définissant
    // l'élément HTML `.transition-example--message` comme cible de l'animation,
    // le nom `display` comme préfixe remplaçant `…` dans les noms de transition et
    // le nom `is-displayed` comme état alternatif après animation entrante.
    transition(message, "animate", "is-displayed");
});

Résultat :

Tu me vois !

L'état facultatif

Imaginons à présent que la totalité des propriétés CSS pour les transitions que l'on applique est compatible pour une transition (pas de présence de la propriété CSS display par exemple). Nous n'aurions alors pas besoin de gérer d'état. Sans état, nous ferrions alors le choix de deviner si nous devons effectuer une transition entrante ou sortante. Pour cela, nous pourrions nous baser sur la présence des classes …-enter-to ou de …-leave qui sont deux cas possibles qui indiquent que l'animation doit être une transition sortante. Dans le cas inverse, ce serait une transition entrante.

Nous allons donc rendre cela possible avec la fonction transition en utilisant le HTML et la CSS suivants :

HTML :

<!-- Note : similaire à l'exemple précédent avec le nom -->
<!-- des classes et textes différents. -->
<div class="stateless-example">
    <button class="stateless-example--button">Animer</button>
    <div class="stateless-example--message">Animez-moi !</div>
</div>

CSS :

/* Dans notre exemple, */
/* l'état de début de la transition entrante */
/* et l'état de fin de la transition sortante */
/* sont les mêmes. */
.stateless-example--message.animate-enter,
.stateless-example--message.animate-leave-to {
    transform: translateX(0)
}

/* Dans notre exemple, */
/* l'état de début de la transition sortante */
/* et l'état de fin de la transition entrante */
/* sont les mêmes. */
.stateless-example--message.animate-leave,
.stateless-example--message.animate-enter-to {
    transform: translateX(200px)
}

/* Nous continuons cependant à utiliser une asymétrie de temps. */
.stateless-example--message.animate-enter-active {
    transition: transform 1s;
}
.stateless-example--message.animate-leave-active {
    transition: transform 4s;
}

JavaScript

var button, message;

// Nous allons rendre le `state` facultatif et dans ce cas faire gérer
// le changement d'état par la présence des classes de transition.

/**
 * Permet d'exécuter une transition entre deux états pour un élément spécifique.
 * @param  {HTMLElement} target     - L'élément HTML qui doit être animé.
 * @param  {string}      transition - Le nom de la transition remplaçant `…` pour les classes `…-enter`, `…-leave`, etc.
 * @param  {string}      [state]    - L'état placé au début d'une transition entrante et retiré en fin d'une transition sortante.
 */
function transition(target, transition, state) {
    var switchBase,
        // Est-ce qu'un état a été sciemment défini ?
        hasState = typeof state === 'string';

    // Gestion du critère de choix de transition entrante ou sortante.
    if (hasState) {

        // On utilise la présence de l'état défini.
        switchBase = target.classList.contains(state);
    } else {

        // On devine l'état suivant à partir des classes de transition sur l'élément.
        switchBase = target.classList.contains(transition + "-enter-to") || target.classList.contains(transition + "-leave")
    }

    // Transition entrante.
    if (!switchBase) {

        // On vérifie s'il y a un état avant d'appliquer
        // le changement d'état qui indique l'état alternatif.
        hasState && target.classList.add(state);

        target.classList.remove(transition + "-leave-to");
        target.classList.add(transition + "-enter");

        setTimeout(function() {
            target.classList.add(transition + "-enter-active");

            setTimeout(function() {
                target.classList.remove(transition + "-enter");
                target.classList.add(transition + "-enter-to");

                setTimeout(function() {
                    target.classList.remove(transition + "-enter-active");
                }, 1000);
            }, 0);
        }, 0);

    // Transition sortante.
    } else {
        target.classList.remove(transition + "-enter-to");
        target.classList.add(transition + "-leave");

        setTimeout(function() {
            target.classList.add(transition + "-leave-active");

            setTimeout(function() {
                target.classList.remove(transition + "-leave");
                target.classList.add(transition + "-leave-to");

                setTimeout(function() {
                    target.classList.remove(transition + "-leave-active");

                    // On vérifie s'il y a un état avant d'appliquer
                    // le changement d'état qui indique l'état standard.
                    hasState && target.classList.remove(state);
                }, 4000);
            }, 0);
        }, 0);
    }
}

button = document.getElementsByClassName("stateless-example--button")[0];
message = document.getElementsByClassName("stateless-example--message")[0];

button.addEventListener("click", function() {

    // On applique les transitions en définissant
    // l'élément HTML `.transition-example--message` comme cible de l'animation et
    // le nom `animate` comme préfixe remplaçant `…` dans les noms de transition.
    transition(message, "animate");
});

Résultat :

Animez-moi !

Temps automatiquement calculé

Il ne vous aura certainement pas échappé que jusqu'à maintenant, nous indiquions manuellement que la transition entrante s'arrête au bout de 1000 millisecondes et que la transition sortante s'arrête au bout de 4000 millisecondes directement dans le code JavaScript. Nous allons créer une fonction qui calcule ce temps en fonction des instructions de transition dans la CSS. Profitons en également pour permettre de définir manuellement cette valeur lors de l'utilisation de la fonction transition.

HTML :

<!-- Note : identique à l'exemple précédent. -->
<div class="stateless-example">
    <button class="stateless-example--button">Animer</button>
    <div class="stateless-example--message">Animez-moi !</div>
</div>

CSS :

/* Note : identique à l'exemple précédent. */
.stateless-example--message.animate-enter,
.stateless-example--message.animate-leave-to {
    transform: translateX(0)
}

.stateless-example--message.animate-leave,
.stateless-example--message.animate-enter-to {
    transform: translateX(200px)
}

.stateless-example--message.animate-enter-active {
    transition: transform 1s;
}
.stateless-example--message.animate-leave-active {
    transition: transform 4s;
}

JavaScript

var button, message;

// Nous allons permettre de définir nous même les transitions
// ou de les calculer automatiquement sans précisions à l'utilisation.

/**
 * Permet d'exécuter une transition entre deux états pour un élément spécifique.
 * @param  {HTMLElement}   target           - L'élément HTML qui doit être animé.
 * @param  {string}        transition       - Le nom de la transition remplaçant `…` pour les classes `…-enter`, `…-leave`, etc.
 * @param  {string}        [state]          - L'état placé au début d'une transition entrante et retiré en fin d'une transition sortante.
 * @param  {number|Object} [time]           - Si c'est un `number`, spécifie la durée de la transition entrante et sortante.
                                              Si elle n'est pas précisée, cette durée est calculée à partir de la propriété `transition` ou `animation`.
 * @param  {number}        [time.enterTime] - Spécifie la durée de la transition entrante.
                                              Le temps pour la transition sortante sera calculé si `time.leaveTime` n'est pas définie.
 * @param  {number}        [time.leaveTime] - Spécifie la durée de la transition sortante.
                                              Le temps pour la transition entrante sera calculé si `time.leaveTime` n'est pas définie.
 */
function transition(target, transition, state, time) {
    var switchBase,

        // Un état est défini ?
        hasState = typeof state === 'string',

        // Un temps de transition entrante est défini ?
        hasEnterTime = time && typeof time.enterTime === 'number',

        // Un temps de transition sortante est défini ?
        hasLeaveTime = time && typeof time.leaveTime === 'number',

        // Au moins un temps est défini ?
        hasTime = typeof time === 'number' || hasEnterTime || hasLeaveTime;

    // Nous créons une fonction qui extrais le temps le plus
    // long dans une série de transition.
    function mostLongest(target) {
        var max = 0,
            hasTransition = getComputedStyle(target)['transition'],
            hasAnimation = getComputedStyle(target)['animation'],
            directive = hasTransition + ', ' + hasAnimation;

        // On parcours toutes les transitions/animations.
        directive.split(',').forEach(function (item) {

            // On extrait les valeurs te temps
            item.match(/([.0-9]+)s/g).forEach(function (item) {

                // et on retire les `s` et convertissons le temps
                // en milliseconde (ex `'4s'` devient `4000`).
                var time = item.replace(/s/g, '') * 1000;
                if (time > max) {

                    // On ne garde que le temps le plus long.
                    max = time;
                }
            });
        });

        return max;
    }

    // Si le temps est précisé par l'utilisateur.
    if (hasTime) {

        // Alors on affecte ce temps globalement
        // ou spécifiquement pour la transition d'entrée
        enterTime = (hasEnterTime) ? time.enterTime : time;

        // ou spécifiquement pour la transition de sortie.
        leaveTime = (hasLeaveTime) ? time.leaveTime : time;
    }

    if (hasState) {
        switchBase = target.classList.contains(state);
    } else {
        switchBase = target.classList.contains(transition + "-enter-to") || target.classList.contains(transition + "-leave")
    }

    // Transition entrante.
    if (!switchBase) {
        hasState && target.classList.add(state);

        target.classList.remove(transition + "-leave-to");
        target.classList.add(transition + "-enter");

        setTimeout(function() {
            target.classList.add(transition + "-enter-active");

            // Si rien n'est précisé globalement,
            // ou rien n'est spécifié spécifiquement pour la transition entrante,
            if (!hasTime || (hasTime && !hasEnterTime && hasLeaveTime)) {

                // on récupère cette valeur depuis la CSS.
                enterTime = mostLongest(target);
            }

            setTimeout(function() {
                target.classList.remove(transition + "-enter");
                target.classList.add(transition + "-enter-to");

                setTimeout(function() {
                    target.classList.remove(transition + "-enter-active");
                }, enterTime);
            }, 0);
        }, 0);

    // Transition sortante.
    } else {
        target.classList.remove(transition + "-enter-to");
        target.classList.add(transition + "-leave");

        setTimeout(function() {
            target.classList.add(transition + "-leave-active");

            // Si rien n'est précisé globalement,
            // ou rien n'est spécifié spécifiquement pour la transition sortante,
            if (!hasTime || (hasTime && !hasLeaveTime && hasEnterTime)) {

                // on récupère cette valeur depuis la CSS.
                leaveTime = mostLongest(target);
            }

            setTimeout(function() {
                target.classList.remove(transition + "-leave");
                target.classList.add(transition + "-leave-to");

                setTimeout(function() {
                    target.classList.remove(transition + "-leave-active");

                    hasState && target.classList.remove(state);
                }, leaveTime);
            }, 0);
        }, 0);
    }
}

button = document.getElementsByClassName("stateless-example--button")[0];
message = document.getElementsByClassName("stateless-example--message")[0];

button.addEventListener("click", function() {

    // Temps auto-calculé depuis la CSS.
    transition(message, "animate");

    // Exemple de temps mis à la main.
    /* transition(message, "animate", undefined, {
        timeEnter: 1000,
        timeLeave: 4000
    }); */

    // Temps mis à la main en mode raccourci si les temps
    // pour la transition entrante et sortante sont identiques.
    /* transition(message, "animate", undefined, 2000); */
});

Résultat :

Animez-moi !

Fonctions de rappel

Maintenant que nous sommes capable de gérer le temps de l'animation, nous allons gérer des fonctions que nous pourrons appeler quand l'animation sera terminée. Cela nous permettra de faire des animations en chaîne ou simplement de changer l'état manuellement, sans que ce soit la fonction de transition qui s'en occupe.

Nous allons également rassembler la totalité de ces actions sous un unique troisième paramètre pour avoir à éviter de mettre des valeurs de paramètre à undefined comme c'est le cas ici : transition(message, "animate", undefined, 2000). Aussi si le troisième paramètre est une string, il s'agira de faire gérer une classe d'état par la fonction transition, si c'est un number de créer un temps avant fin d'animation identique pour les transitions entrante ou sortante et si c'est un objet, nous gérerons la totalité des options.

Notez que toutes ces fonctions de rappels sont gérés en tant que point d'ancrage (« hooks ») par Vue.js. Vous pouvez vous en inspirer pour ajouter autant de fonction de rappel que vous le souhaitez aux moments clés de la fonction transition.

HTML :

<!-- Note : identique à l'exemple précédent. -->
<div class="stateless-example">
    <button class="stateless-example--button">Animer</button>
    <div class="stateless-example--message">Animez-moi !</div>
</div>

CSS :

.stateless-example--message {
    color: #000;
}
.stateless-example--message.is-highlighted {
    color: #ccc;
}

.stateless-example--message.animate-enter,
.stateless-example--message.animate-leave-to {
    transform: translateX(0)
}

.stateless-example--message.animate-leave,
.stateless-example--message.animate-enter-to {
    transform: translateX(200px)
}

.stateless-example--message.animate-enter-active {
    transition: transform 1s;
}
.stateless-example--message.animate-leave-active {
    transition: transform 4s;
}

JavaScript

var button, message;

// Nous allons permettre de définir nous même des fonctions de rappel en début et fin de transition.

/**
 * Permet d'exécuter une transition entre deux états pour un élément spécifique.
 * @param  {HTMLElement}                   target                    - L'élément HTML qui doit être animé.
 * @param  {string}                        transition                - Le nom de la transition remplaçant `…` pour les classes `…-enter`, `…-leave`, etc.
 * @param  {string|number|function|Object} [options]                 - Si c'est une `string`, spécifie l'état placé au début d'une transition entrante
                                                                       et retiré en fin d'une transition sortante.
                                                                     - Si c'est un `number`, spécifie la durée de la transition entrante et sortante.
                                                                     - Si c'est une `function`, défini la fonction de rappel de début de transition entrante `enterCallback`
                                                                       et de fin de transition sortante `leaveToCallback`.
                                                                     - Si c'est un objet, voir le détail de chaque propriété.
 * @param  {string}                        [options.state]           - Spécifie l'état placé au début d'une transition entrante et retiré en fin d'une transition sortante.
 * @param  {number}                        [options.time]            - Spécifie la durée de la transition entrante et sortante.
 * @param  {number}                        [options.enterTime]       - Spécifie la durée de la transition entrante.
                                                                       Le temps pour la transition sortante sera calculé si `time.leaveTime` n'est pas définie.
 * @param  {number}                        [options.leaveTime]       - Spécifie la durée de la transition sortante.
                                                                       Le temps pour la transition entrante sera calculé si `time.leaveTime` n'est pas définie.
 * @param  {function}                      [options.enterCallback]   - Spécifie une fonction a exécuter au début de la transtion entrante.
                                                                       Paramètres de la fonction de rappel : `target`, `transition`, `params`, `options`.
 * @param  {function}                      [options.enterToCallback] - Spécifie une fonction a exécuter à la fin de la transtion entrante.
                                                                       Paramètres de la fonction de rappel : `target`, `transition`, `params`, `options`.
 * @param  {function}                      [options.leaveCallback]   - Spécifie une fonction a exécuter au début de la transtion sortante.
                                                                       Paramètres de la fonction de rappel : `target`, `transition`, `params`, `options`.
 * @param  {function}                      [options.leaveToCallback] - Spécifie une fonction a exécuter à la fin de la transtion sortante.
                                                                       Paramètres de la fonction de rappel : `target`, `transition`, `params`, `options`.
 */
function transition(target, transition, options) {
    var hasNoState,
        params = {};

    function mostLongest(target) {
        var max = 0,
            hasTransition = getComputedStyle(target)['transition'],
            hasAnimation = getComputedStyle(target)['animation'],
            directive = hasTransition + ', ' + hasAnimation;

        directive.split(',').forEach(function (item) {
            item.match(/([.0-9]+)s/g).forEach(function (item) {
                var time = item.replace(/s/g, '') * 1000;
                if (time > max) {
                    max = time;
                }
            });
        });
        return max;
    }

    // Si aucune options n'est passée, la liste des options sera un objet vide.
    if (options === undefined || options === null) {
        options = {};
    }

    // Nous gérons la présence ou nom d'un état d'avant et d'après transition.
    params.state = options.state || (typeof options === 'string' ? options : undefined);

    // Nous gérons la présence d'un temps souhaité plutôt que calculé automatiquement.
    params.enterTime = options.enterTime || options.time || (typeof options === 'number' ? options : undefined);
    params.leaveTime = options.leaveTime || options.time || (typeof options === 'number' ? options : undefined);

    // Nous gérons la possibilité de faire appel à des fonctions de rappel en début et fin de transition entrante et de sortante.
    params.enterCallback = options.enterCallback || (typeof options === 'function' ? options : undefined);
    params.enterToCallback = options.enterToCallback;
    params.leaveCallback = options.leaveCallback;
    params.leaveToCallback = options.enterToCallback || (typeof options === 'function' ? options : undefined);

    // Élément permettant de savoir si on est sur une transition entrante ou sortante.
    if (params.state) {
        hasNoState = target.classList.contains(params.state);
    } else {
        hasNoState = target.classList.contains(transition + "-enter-to") || target.classList.contains(transition + "-leave")
    }

    // Transiditon entrante.
    if (!hasNoState) {

        // Si un état est précisé, on l'applique en début de transition entrante.
        params.state && target.classList.add(params.state);

        // Appel de la fonction de début de transition entrante.
        if (params.enterCallback) {
            params.enterCallback(target, transition, params, options);
        }

        target.classList.remove(transition + "-leave-to");
        target.classList.add(transition + "-enter");

        setTimeout(function() {
            target.classList.add(transition + "-enter-active");
            if (!params.enterTime) {
                params.enterTime = mostLongest(target);
            }

            setTimeout(function() {
                target.classList.remove(transition + "-enter");
                target.classList.add(transition + "-enter-to");

                setTimeout(function() {
                    target.classList.remove(transition + "-enter-active");

                    // Appel de la fonction de fin de transition sortante.
                    if (params.enterToCallback) {
                        params.enterToCallback(target, transition, params, options);
                    }
                }, params.enterTime);
            }, 0);
        }, 0);

    // Transition sortante.
    } else {

        // Appel de la fonction de début de transition sortante.
        if (params.leaveCallback) {
            params.leaveCallback(target, transition, params, options);
        }

        target.classList.remove(transition + "-enter-to");
        target.classList.add(transition + "-leave");

        setTimeout(function() {
            target.classList.add(transition + "-leave-active");
            if (!params.leaveTime) {
                params.leaveTime = mostLongest(target);
            }

            setTimeout(function() {
                target.classList.remove(transition + "-leave");
                target.classList.add(transition + "-leave-to");

                setTimeout(function() {
                    target.classList.remove(transition + "-leave-active");

                    // Si un état est précisé, on l'applique en fin de transition sortante.
                    params.state && target.classList.remove(params.state);

                    // Appel de la fonction de fin de transition sortante.
                    if (params.leaveToCallback) {
                        params.leaveToCallback(target, transition, params, options);
                    }
                }, params.leaveTime);
            }, 0);
        }, 0);
    }
}

button = document.getElementsByClassName("stateless-example--button")[0];
message = document.getElementsByClassName("stateless-example--message")[0];

button.addEventListener("click", function() {

    // On utilise la même fonction appelé en début de
    // transition entrante et en fin de transition sortante.
    transition(message, "animate", function (target, transition, params, options) {
        target.classList.toggle("is-highlighted");
    });

    // On spécifie ici des fonctions différentes en début de
    // transition entrante et en fin de transition sortante.
    /* transition(message, "animate", {
        enterCallback: function (target, transition, params, options) {
            target.classList.add("is-highlighted");
        },
        leaveToCallback: function (target, transition, params, options) {
            target.classList.remove("is-highlighted");
        }
    }); */
});

Résultat :

Animez-moi !

Media Queries et délai de boucle

En fonction de la taille d'affichage, il est possible que vous ne souhaitez pas initier de transition. Nous allons gérer cela en conditionnant le mécanisme de transition par une Média Query. Également, dans certains cas, il est possible que vous préfériez que la transition s'exécute à tout les coups au détriment de la précision au millième de milliseconde. Nous allons pour cela vous laisser la main sur le temps qu'il va se passer entre chaque setTimeout dans notre fonction.

<!-- Note : similaire à l'exemple précédent avec les nom des classes différent. -->
<div class="larger-example">
    <button class="larger-example--button">Animer</button>
    <div class="larger-example--message">Animez-moi !</div>
</div>

CSS :

.larger-example--message.is-highlighted {
    transform: translateX(200px);
    color: #000;
}
.larger-example--message:not(.is-highlighted) {
    transform: translateX(0);
    color: #ccc;
}

/* On autorise l'animation que si */
/* l'affichage est supérieur à 720px. */
@media (min-width: 720px) {
    .larger-example--message.animate-enter,
    .larger-example--message.animate-leave-to {
        transform: translateX(0);
    }

    .larger-example--message.animate-leave,
    .larger-example--message.animate-enter-to {
        transform: translateX(200px);
    }

    .larger-example--message.animate-enter-active {
        transition: transform 1s;
    }
    .larger-example--message.animate-leave-active {
        transition: transform 4s;
    }
}

JavaScript

var button, message;

// Nous allons permettre de gérer les animations que pour une certaine taille d'affichage (ou pour des appareils plus lents).

/**
 * Permet d'exécuter une transition entre deux états pour un élément spécifique.
 * @param  {HTMLElement}                   target                    - L'élément HTML qui doit être animé.
 * @param  {string}                        transition                - Le nom de la transition remplaçant `…` pour les classes `…-enter`, `…-leave`, etc.
 * @param  {string|number|function|Object} [options]                 - Si c'est une `string`, spécifie l'état placé au début d'une transition entrante
                                                                       et retiré en fin d'une transition sortante.
                                                                     - Si c'est un `number`, spécifie la durée de la transition entrante et sortante.
                                                                     - Si c'est une `function`, défini la fonction de rappel de début de transition entrante `enterCallback`
                                                                       et de fin de transition sortante `leaveToCallback`.
                                                                     - Si c'est un objet, voir le détail de chaque propriété.
 * @param  {string}                        [options.state]           - Spécifie l'état placé au début d'une transition entrante et retiré en fin d'une transition sortante.
 * @param  {number}                        [options.time]            - Spécifie la durée de la transition entrante et sortante.
 * @param  {number}                        [options.enterTime]       - Spécifie la durée de la transition entrante.
                                                                       Le temps pour la transition sortante sera calculé si `time.leaveTime` n'est pas définie.
 * @param  {number}                        [options.leaveTime]       - Spécifie la durée de la transition sortante.
                                                                       Le temps pour la transition entrante sera calculé si `time.leaveTime` n'est pas définie.
 * @param  {number}                        [options.tickDelay]       - Spécifie le délai utilisé entre chaque étape de gestion de transition.
 * @param  {number}                        [options.mediaQueries]    - Spécifie des instructions de Media Queries pour la transition courante.
 * @param  {function}                      [options.fallback]        - Spécifie une fonction a exécuter quand les Media Queries ne sont pas respectées.
                                                                       Paramètres de la fonction de secour : `target`, `transition`, `params`, `options`.
 * @param  {function}                      [options.enterCallback]   - Spécifie une fonction a exécuter au début de la transtion entrante.
                                                                       Paramètres de la fonction de rappel : `target`, `transition`, `params`, `options`.
 * @param  {function}                      [options.enterToCallback] - Spécifie une fonction a exécuter à la fin de la transtion entrante.
                                                                       Paramètres de la fonction de rappel : `target`, `transition`, `params`, `options`.
 * @param  {function}                      [options.leaveCallback]   - Spécifie une fonction a exécuter au début de la transtion sortante.
                                                                       Paramètres de la fonction de rappel : `target`, `transition`, `params`, `options`.
 * @param  {function}                      [options.leaveToCallback] - Spécifie une fonction a exécuter à la fin de la transtion sortante.
                                                                       Paramètres de la fonction de rappel : `target`, `transition`, `params`, `options`.
 */
function transition(target, transition, options) {
    var hasNoState
        mediaQueriesTest = false,
        params = {};

    function mostLongest(target) {
        var max = 0,
            hasTransition = getComputedStyle(target)['transition'],
            hasAnimation = getComputedStyle(target)['animation'],
            directive = hasTransition + ', ' + hasAnimation;

        directive.split(',').forEach(function (item) {
            item.match(/([.0-9]+)s/g).forEach(function (item) {
                var time = item.replace(/s/g, '') * 1000;
                if (time > max) {
                    max = time;
                }
            });
        });
        return max;
    }

    if (options === undefined || options === null) {
        options = {};
    }

    params.state = options.state || (typeof options === 'string' ? options : undefined);
    params.enterTime = options.enterTime || options.time || (typeof options === 'number' ? options : undefined);
    params.leaveTime = options.leaveTime || options.time || (typeof options === 'number' ? options : undefined);
    params.enterCallback = options.enterCallback || (typeof options === 'function' ? options : undefined);
    params.enterToCallback = options.enterToCallback;
    params.leaveCallback = options.leaveCallback;
    params.leaveToCallback = options.enterToCallback || (typeof options === 'function' ? options : undefined);

    // Nous attachons une fonction de rappel en cas de non respect des Media Queries.
    params.fallback = options.fallback;

    // Nous permettons de fixer le temps entre chaque `tick` d'étape de gestion de la transition.
    params.tickDelay = options.tickDelay || 0;

    // Nous gérons les conditions de fonctionnement de la transition.
    mediaQueriesTest = (options.mediaQueries) ? window.matchMedia(options.mediaQueries).matches : false;

    if (params.state) {
        hasNoState = target.classList.contains(params.state);
    } else {
        hasNoState = target.classList.contains(transition + "-enter-to") || target.classList.contains(transition + "-leave")
    }

    // On enclenche le mécanisme uniquement s'il existe des Media Queries respectées où s'il n'en existe pas.
    if (options.mediaQueries && mediaQueriesTest || !options.mediaQueries) {

        // Transition entrante.
        if (!hasNoState) {
            params.state && target.classList.add(params.state);

            if (params.enterCallback) {
                params.enterCallback(target, transition, params, options);
            }

            target.classList.remove(transition + "-leave-to");
            target.classList.add(transition + "-enter");

            setTimeout(function() {
                target.classList.add(transition + "-enter-active");
                if (!params.enterTime) {
                    params.enterTime = mostLongest(target);
                }

                setTimeout(function() {
                    target.classList.remove(transition + "-enter");
                    target.classList.add(transition + "-enter-to");

                    setTimeout(function() {
                        target.classList.remove(transition + "-enter-active");

                        if (params.enterToCallback) {
                            params.enterToCallback(target, transition, params, options);
                        }
                    }, params.enterTime);

                // On applique les délais.
                }, params.tickDelay);
            }, params.tickDelay);

        // Transition sortante.
        } else {
            if (params.leaveCallback) {
                params.leaveCallback(target, transition, params, options);
            }

            target.classList.remove(transition + "-enter-to");
            target.classList.add(transition + "-leave");

            setTimeout(function() {
                target.classList.add(transition + "-leave-active");
                if (!params.leaveTime) {
                    params.leaveTime = mostLongest(target);
                }

                setTimeout(function() {
                    target.classList.remove(transition + "-leave");
                    target.classList.add(transition + "-leave-to");

                    setTimeout(function() {
                        target.classList.remove(transition + "-leave-active");

                        params.state && target.classList.remove(params.state);

                        if (params.leaveToCallback) {
                            params.leaveToCallback(target, transition, params, options);
                        }
                    }, params.leaveTime);

                // On applique les délais.
                }, params.tickDelay);
            }, params.tickDelay);
        }
    } else {

        // Si les Media Queries ne sont pas respectées
        // on retire les classes de transitions.
        target.classList.remove(transition + "-enter");
        target.classList.remove(transition + "-enter-to");
        target.classList.remove(transition + "-leave");
        target.classList.remove(transition + "-leave-to");

        // On laisse alors la possibilité de gérer ce qu'il se passe dans ce cas.
        if (params.fallback) {
            params.fallback(target, transition, params, options);
        }
    }
}

button = document.getElementsByClassName("larger-example--button")[0];
message = document.getElementsByClassName("larger-example--message")[0];

button.addEventListener("click", function() {

    // Redimentionnez votre fenêtre pour voir la différence entre une
    // taille supérieure et inférieure à 720px.
    transition(message, "animate", {
        state: 'is-highlighted',
        mediaQueries: '(min-width: 720px)',
        fallback: function (target, transition, params, options) {
            target.classList.toggle("is-highlighted");
        }
    });
});

Résultat :

Animez-moi !

Le mot de la fin

Je suppose qu'il existe des tas d'idées pour rendre cette fonction encore plus cool. Il y a sûrement également des cas limites ou elle ne fonctionne plus. Je vous laisse voir ça de votre côté mais vous avez les bases pour continuer un tel travail. N'oubliez pas également que une fonctionnalité équivalence qui gère sûrement ces cas limites existe déjà dans Vue.js : allez tester !

Si vous trouvez des bugs et solutions dans les exemples suivants, n'hésitez pas à le faire savoir en commentaire !

Lire dans une autre langue