Animations avec JavaScript & RequestAnimationFrame
Depuis quelques mois, j’oriente mon temps de R&D vers la spécification SVG et les différentes possibilités d’interactions avec CSS et Javascript.
Dans un proche avenir, les spécifications Web Animation API, SVG2 et indirectement CSS custom properties vont grandement faciliter la création d’animations SVG.
Mais si vous êtes tenu de supporter la majorité des navigateurs, dont IE111, requestAnimationFrame
reste la seule solution :
- SVG SMIL n’a jamais été implémenté ni dans Internet Explorer ni dans Edge et va devenir obsolète pour les navigateurs à base du moteur Blink.
- CSS permet des interactions, mais elles restent assez limitées avec SVG 1.1, comme l’impossibilité de modifier les coordonnées des tracés, les transformations sur les dégradés, motifs ou encore appliquer une transformation sur un symbole.
- Charger un framework d’animation pour animer une icône, c’est un peu comme sortir un bazooka pour tuer une mouche.
Cet article présente une synthèse de pratiques et méthodes d’animations avec requestAnimationFrame
issues de divers articles et de tests. Les exemples sont valables pour animer des éléments HTML, SVG ou même un canvas.
Pour rendre ma progression plus ludique, les différentes étapes détaillent chaque aspect nécessaire à la réalisation de cette animation SVG (que j’ai partagé pour mes vœux 2018), en augmentant progressivement la complexité et les détails.
See the Pen raf12-Last by Thomas Jund (@Sacripant) on CodePen.
Bonne lecture.
RequestAnimationFrame basic
const frame = now => {
console.log(now)
};
requestAnimationFrame(frame);
En exécutant la fonction frame
au sein d’une requestAnimationFrame
vous indiquez au navigateur que vous souhaitez exécuter la fonction avant le prochain rafraichissement du navigateur.
requestAnimationFrame
retourne en callback un paramètre (ici nommé now
), qui est un DOMHighResTimeStamp : un TimeStamp super précis (microseconde) indiquant le moment ou le navigateur effectuera son rafraichissement.
frame
n’est exécutée qu’une seule fois. Pour qu’il y ait animation, il faut boucler la fonction.
Boucler et annuler
Pour créer une boucle, il faut récursivement relancer votre fonction au sein d’une requestAnimationFrame
. Pour quitter la boucle, il suffit d’utiliser la fonction cancelAnimationFrame
, ou sortir de la boucle par une condition.
let rafID = null;
let counter = 0;
const frame = now => {
counter ++;
console.log(now);
if (counter === 10) {
cancelAnimationFrame(rafID);
counter = 0;
rafID = null;
return;
}
rafID = requestAnimationFrame(frame);
};
rafID = requestAnimationFrame(frame);
Dans cet exemple, un compteur est incrémenté à chaque frame. Lorsque le compteur arrive à 10, la boucle est annulée.
En inspectant la console, on remarque que la fonction frame est exécutée environ toutes les 16ms, ce qui correspond environ à 60 frames par seconde. Mais cette vitesse d’exécution n’est pas garantie et va dépendre de la complexité de votre fonction, de la puissance du terminal, du nombre d’onglets ouverts, etc.
Progression
Pour créer une transition, il faut partir d’une situation initiale, arriver à une situation finale et maitriser la progression. La fonction frame
calculera chaque étape de la progression.
Nous avons donc besoin qu’une variable, variant de 0
à 1
, indique à chaque frame la position de la progression.
0 = situation initiale.
1 = situation finale.
const progress = indexFrameCourante ÷ NbTotaleFrames
Prenons l’exemple d’une animation qui doit s’exécuter en 10 frames :
let rafID = null;
// Index frame courante
let f = 0;
// Nombre de Frames de l'animation
const NF = 10;
const frame = now => {
f ++;
// define progress
const progress = f/NF;
console.log(progress);
if (f >= NF) {
cancelAnimationFrame(rafID);
f = 0;
rafID = null;
return;
}
rafID = requestAnimationFrame(frame);
};
rafID = requestAnimationFrame(frame);
Dans cet exemple la variable progress
retournera donc …
0.1
0.2
0.3
…
1
…correspondant aux 10 étapes de l’animation. Il suffit de modifier la valeur de NF
pour avoir une animation plus ou moins longue.
Animation d’une illustration SVG
Commençons par animer un regard.
Le dessin de l’œil est défini au sein d’un symbol
cloné 2x via la balise use
. En modifiant les attributs des éléments composant le symbole, les différents clones seront animés.
Pour déplacer l’iris vers la droite en faisant varier l’attribut transfom:translate()
, d’une valeur initiale de 0
à 23
, il suffit d’exploiter la valeur retournée par la variable progress
, en la multipliant par la valeur finale : progress * finalPosition
.
const animePupil = (function(){
// …
const irisPupil = document.getElementById('irisPupil');
const frame = () => {
// …
irisPupil.setAttribute('transform', `translate( ${progress*23} )`);
// …
};
const play = () => {
rafID = requestAnimationFrame(frame);
};
return {
play: play
};
}());
startBtn.addEventListener('click', () => {
animePupil.play();
}, false);
See the Pen raf1-simpleTranslate by Thomas Jund (@Sacripant) on CodePen.
Distance
Dans le cas où la valeur initiale n’est pas 0
, mais une autre valeur, la distance à parcourir doit être calculée par position.end - position.start
. Et la valeur de positionnement pour chaque frame sera position.start + progress * distance
.
Pour une position de départ de -23
et d’arrivée de 23
:
// …
// Calcul de la distance
const range = (min, max) => max - min;
// transformation start -> end
const transform = (progress, {start, end}) => start + progress * range(start, end);
const animePupil = (function(){
// …
const animDatas = {
irisPupil : {
translateX : {start: -23, end: 23 }
}
};
const frame = () => {
// …
irisPupil.setAttribute('transform', `translate(
${transform(progress, animDatas.pupil.translateX)}
)`);
// …
};
}());
//…
See the Pen raf2-simpleTranslate by Thomas Jund (@Sacripant) on CodePen.
Inverser
Pour inverser une animation il suffit de faire décroitre l’index de la variable f
qui aura pour effet d’inverser également les valeurs de la progression.
Nous allons ainsi ajouter :
- une variable
dir
dont on fera alterner la valeur entre1
et-1
; - remplacer au sein de la fonction
frame
la simple incrémentation def++
parf += dir
; - et modifier la condition de sortie de la boucle
if( f >= NF )
parif( !(f%NF) )
pour retourner une conditiontrue
lorsquef==0 || f==NF
.
// …
const animePupil = (function(){
// …
let dir = 1;
// …
const frame = () => {
f += dir;
// …
if ( !(f%NF) ) {
// On ne stop plus l'animation
// Mais on l'inverse
dir *= -1;
}
// …
}
// On stoppe l'animation via un btn, sans réinitialiser f
stopBtn.addEventListener('click', e => {
cancelAnimationFrame(rID);
rID = null;
}, false);
}());
See the Pen raf3-vaEtVient by Thomas Jund (@Sacripant) on CodePen.
En conservant la valeur de f
, l’animation reprend à l’endroit où on l’a stoppée. De plus, si on clique plusieurs fois sur startBtn
, l’animation va s’accélérer, en additionnant les requestAnimationFrame
. Un nombre équivalent de clics sur stopBtn
sera alors nécessaire pour ralentir et stopper l’animation.
Pour éviter cet effet de bord, il suffit de vérifier si rafID
est null
au sein de la fonction play
.
const play = () => {
if (rafID) return;
rafID = requestAnimationFrame(frame);
};
On peut également ajouter une pause entre le va et le vient à l’aide d’un setTimeOut
:
const animePupil = (function(
// …
let replay;
// …
const frame = () => {
// …
if (!(f%NF)) {
dir *= -1;
stop();
replay = setTimeout( () => {
play();
}, 2500);
return;
}
rafID = requestAnimationFrame(frame);
};
// …
const stop = () => {
clearTimeout(replay);
// …
};
}());
See the Pen raf4-vaEtVient by Thomas Jund (@Sacripant) on CodePen.
De la ligne à la courbe
Tout cela est bien trop plat.
L’animation est rendue de manière linéaire : avec une vitesse constante. Pour moduler la vitesse de progression via une timing function2 de type ease-in il faut modifier la valeur de progress
via une fonction mathématique souvent empreinte de trigonométrie. Si pour vous, comme pour moi, la pratique des mathématiques n’a jamais été votre fort, la majorité des fonctions a déjà été écrite par la communauté (voir ressources en fin d’article).
Timing functions
Passer la valeur de progress
au sein d’une timing functions de type ease-in va avoir pour effet de ralentir la transition au début pour l’accélérer ensuite, celles de type ease-out vont faire l’inverse.a
// …
// timing functions
const tfn = {
easeOutCubic : t => Math.pow(t-1, 3) + 1,
easeInCubic : t => Math.pow(t, 3),
easeInQuad : t => t*t,
easeOutQuad : t => t*(2-t),
};
const animePupil = (function(){
// …
const frame = () => {
// …
const progress = f/NF;
const ease = tfn.easeOutCubic(progress);
//…
irisPupil.setAttribute('transform', `translate(
${transform(ease, animDatas.irisPupil.translateX)}
)`);
// …
Appliquer une tfn.easeInCubic
va donc fortement accélérer la transition au début, pour la ralentir sur la fin. Mais dans le cas de notre animation, la transition effectue un va-et-vient. Quand la progression est inversée, notre transition effectue une tfc.easeOutcubic
.
Il faut donc lui appliquer une ftn.easeOutCubic
dans les cas où dir === -1
.
const ease = (dir-1) ? tfn.easeInCubic(progress) : tfn.easeOutCubic(progress);
See the Pen raf5-parabolic1 by Thomas Jund (@Sacripant) on CodePen.
Timing function parabolique ?
Et pourquoi pas.
Une fonction de type parabolique concave permet à la transformation d’atteindre le point d’arrivée au milieu de la progression pour ensuite retourner à la valeur de départ en fin de progressionb. C’est comme faire un va-et-vient à votre mouvement au sein d’une seule progression avec un ralentissement au milieu.
const tfn = {
// …
parabolic : t => -4*Math.pow(t,2) + 4*t
};
const animePupil = (function(){
//…
const frame = () => {
// …
const parabolic = tfn.parabolic(ease);
}
Remarque : ease
étant maintenant la vitesse par défaut pour la translation des yeux, c’est elle qui devient la référente à passer à tfn.parabolic
Utilisons-la pour simuler une rotation du globe oculaire en faisant varier le rayon des abscisses (rx
) de l’iris et des pupilles individuellement pour qu’elles paraissent circulaires au milieu et elliptiques aux bords. Il est nécessaire pour cela de déclarer ces éléments SVG comme des <ellipse/>
plutôt que des <circle/>
et ainsi pouvoir modifier l’attribut rx
.
Pour améliorer l’effet, nous allons légèrement augmenter la translation de la pupille par rapport à l’iris.
Le SVG a été mis à jour pour que le dessin corresponde aux valeurs d’animation de départ modifiées ici.
const animePupil = (function(){
// …
const pupil = document.getElementById('pupil');
const iris = document.getElementById('iris');
const animDatas = {
iris : {
translateX : { start: -23, end: 23 },
rx : { start: 18, end: 22 }
},
pupil : {
translateX : { start: -26, end: 26 },
rx : { start: 6, end: 8 }
}
};
const frame = () => {
// …
// Transform Iris
iris.setAttribute(
'transform',
`translate(
${transform(ease, animDatas.iris.translateX)}
)`
);
iris.setAttribute(
'rx',
transform(parabolic, animDatas.iris.rx)
);
// Transform pupil
pupil.setAttribute(
'transform',
`translate(
${transform(ease, animDatas.pupil.translateX)}
)`
);
pupil.setAttribute(
'rx',
transform(parabolic, animDatas.pupil.rx)
);
// …
See the Pen raf6-parabolic2 by Thomas Jund (@Sacripant) on CodePen.
Il est également possible d’utiliser cette même fonction pour élever le regard en modifiant les valeurs de transformY
const animDatas = {
iris : {
// …
translateY : { start: 0, end: -7 },
},
pupil : {
// …
translateY : { start: 0, end: -8 },
}
};
const frame = () => {
// …
iris.setAttribute(
'transform',
`translate(
${transform(ease, animDatas.iris.translateX)}
${transform(parabolic, animDatas.iris.translateY)}
)`
);
// …
// Transform pupil
pupil.setAttribute(
'transform',
`translate(
${transform(ease, animDatas.pupil.translateX)}
${transform(parabolic, animDatas.pupil.translateY)}
)`
);
//…
See the Pen raf7-tfn by Thomas Jund (@Sacripant) on CodePen.
SVG Path Morphing
Les yeux, ça cligne.
Nous allons animer le tracé du contour de l’œil pour les faire cligner.
Commençons par définir une nouvelle IIFE animeEyelids
avec les mêmes variables et méthodes que la fonction animePupil
utilisée précédemment, à seule nuance qu’il ne sera pas utile d’inverser l’animation.
const animePupil = (function(){
// …
}());
const animeEyelids = (function(){
let rafID = null;
let f = 0;
let replay;
const NF = 20;
const eyeLids = document.getElementById('eyeShape');
const animeDatas = {
// Datas tracés oeil ouvert / fermé
}
const frame = () => {
f += 1;
const progress = f/NF;
const parabolic = tfn.parabolic(progress);
// Votre animation
if (!(f%NF)) {
stop();
replay = setTimeout( () => {
play();
}, 4000);
return;
}
rafID = requestAnimationFrame(frame);
};
const play = () => {
if (rafID) return;
rafID = requestAnimationFrame(frame);
};
const stop = () => {
f = 0;
clearTimeout(replay);
cancelAnimationFrame(rafID);
rafID = null;
};
return {
play: play,
stop: stop
};
}());
Pour animer un tracé (path
) SVG, il faut transformer chaque coordonnée le définissant des valeurs de départ vers les valeurs d’arrivée.
Cela se fera d’autant plus simplement si le type de tracé et la quantité de points sont équivalents dans le tracé de départ et celui d’arrivée.
Voici le tracé qui défini le contour de l’œil paupières ouvertes :
<path d="
M 27,75
S 40 52 75 52 120 75 120 75 105 93 75 93 27 75 27 75
z
" />
et celui qui défini le contour de l’oeil paupières fermées
<paths d="
M 27,75
S 40 93 75 93 120 75 120 75 105 93 75 93 27 75 27 75
z
" />
Les 2 dessins sont définis uniquement à l’aide d’un tracé S
possédant le même nombre de coordonnées.
En stockant les coordonnées du tracé dans un tableau, il est alors possible de mapper les valeurs de départ vers les valeurs d’arrivées au sein de la fonction frame
:
const animeEyelids = (function(){
// …
const animDatas = {
open : [40, 52, 75, 52, 120, 75, 120, 75, 105, 93, 75, 93, 27, 75, 27, 75],
close : [40, 93, 75, 93, 120, 75, 120, 75, 105, 93, 75, 93, 27, 75, 27, 75]
};
const frame = () => {
// …
path = animDatas.open.map((start, index) => {
const datas = {
start : start,
end: animDatas.close[index]
};
const point = transform(parabolic, datas);
return point;
});
eyeLids.setAttribute('d', `M 27 75 S ${path.join(' ')} z`);
// …
See the Pen raf8-morphing by Thomas Jund (@Sacripant) on CodePen.
Remarques : les utilisateurs de Firefox pourront ici visualiser un navrant bug de rendu alors que cela fonctionne sans problème sous un antique ie113.
Une part de hasard
Un coup de dés jamais n’abolira la hasard.
Avoir une animation qui tourne simplement en boucle n’est pas souvent intéressant. Ajouter quelques grains de sables et de hasard est parfois nécessaire.
const random = (min, max) => Math.random() * (max - min) + min;
Cette fonction retourne un nombre aléatoire compris entre les valeurs min
et max
passées en arguments.
Elle permet par exemple de faire varier le délai du setTimeout
qui permet d’ajouter un temps d’attente avant de répéter une séquence :
const animePupil = (function(){
// …
const frame = () => {
// …
if (!(f%NF)) {
// …
replay = setTimeout( () => {
play();
}, random(1500, 3500));
return;
}
// …
const animeEyelids = (function(){
// …
const frame = () => {
// …
if (!(f%NF)) {
stop();
replay = setTimeout( () => {
play();
}, random(1500, 10000));
return;
}
// …
Les yeux peuvent également, parfois, s’arrêter à mi-parcours en définissant un nombre de frames maximum à animer de manière aléatoire :
const animePupil = (function(){
// …
let max;
const maxRandom = () => {
max = Math.round( Math.random() * NF );
};
// …
const frame = () => {
// …
// Suppression du easeInQuad qui n'a va poser des problèmes de rendus en cas de progression partielle
const ease = progress;
// …
if ( f === max || !(f%NF) ) {
//…
}
// …
};
};
const play = () => {
// …
maxRandom();
};
See the Pen raf9-hasard by Thomas Jund (@Sacripant) on CodePen.
Générer et animer une multitude
Via Inkscape, un visage a été ajouté à ce regard. Il nous faut alors définir ce que ces yeux regardent.
Dans cette dernière partie, nous allons cloner et animer le tracé d’un flocon pour générer une légère neige tombante au sein d’une nouvelle IIFE animeSnow
.
Au sein de celle-ci il faut définir une fonction randomInterval
qui exécutera un callback
de manière répétitive au bout d’un certain nombre de frames.
Répéter
const animeSnow = (function(){
let rafID = null;
const randomInterval = (callback, min, max) => {
const NF = Math.round(random(min, max));
let f = 0;
const frame = () => {
f += 1;
if (f >= NF) {
NF = random(min, max);
f = 0;
callback();
}
rafID = requestAnimationFrame(frame);
};
rafID = requestAnimationFrame(frame);
};
const animeFlake = () => {
// Clonage + animation du flocon
};
const play = () => {
if (rafID) return;
randomInterval(animeFlake, 20, 35);
};
// …
}());
La fonction randomInterval
va exécuter la fonction animeFlake
toutes les 20 à 35 frames. Au sein de animeFlake
, on peut maintenant cloner le tracé du flocon et l’animer.
Cloner
Le dessin du flocon est défini au sein d’un symbol
SVG. Pour le cloner il faut créer un nouvel élément use
pointant vers le symbole à l’aide de l’attribut xlink:href
.
const newFlake = () => {
const flake = document.createElementNS('http://www.w3.org/2000/svg', 'use');
flake.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#snowflake');
return flake;
};
const animeFlake = () => {
const flake = newFlake();
svg.appendchild(flake);
};
SVG ayant son propre namespace, il est nécessaire d’utiliser createElementNS
pour créer un élément use
, et setAttributNS
pour lui ajouter un attribut xlink:href
.
Puis en profiter pour personnaliser un peu ce nouveau flocon avec un peu d’aléatoire pour sa position et son épaisseur.
const newFlake = () => {
// …
flake.setAttribute('x', Math.round(random(0, svg.w)) );
flake.style.strokeWidth = `${random(0.5, 2)}px`;
return flake;
};
See the Pen raf10-clone by Thomas Jund (@Sacripant) on CodePen.
Animer chaque clone
Rebelote. Nous allons au sein de la fonction animeFlake
définir les paramètres de l’animation, la fonction frame
, et la lancer de manière récursive avec un requestAnimationFrame
. Lorsque le flocon arrivera au bout de sa progression, il sera retiré du DOM.
const animeFlake = () => {
let flake = newFlake();
let f = 0;
const NF = 500;
const animeDatas = {
translateY : { start: 0, end: 540 },
rotate : { start: 0, end: 100 }
};
const frame = () => {
f += 1;
const progress = f/NF;
const ease = tfn.easeOutCubic(progress);
flake.setAttribute(
'transform',
`translate(
0
${transform(ease, animeDatas.translateY)}
)
rotate(
${transform(ease, animeDatas.rotate)}
)`);
if ( !(f%NF) ) {
svg.removeChild(flake);
return;
}
requestAnimationFrame(frame);
};
svg.appendChild(flake);
requestAnimationFrame(frame);
};
See the Pen raf11-AnimeClone by Thomas Jund (@Sacripant) on CodePen.
Dernières retouches
Les principales briques sont posées et aspects abordés.
Pour arriver au résultat présenté en début d’article, l’animation a subit quelques dernières retouches et modifications :
- l’ajout d’une légère rotation du visage pour accompagner le mouvement des yeux ;
- l’ajout d’un facteur d’échelle aléatoire accompagner d’un filtre SVG Blur sur les flocons pour créer une profondeur de champ ;
- un ajustements de la vitesse (
NF
etsetTimeout
) ; - le debug pour Firefox ;
- l’ajout d’un petit reflet dans les yeux ;
- la conversion du code ES6 en ES5 via BabelJS.
See the Pen raf12-Last by Thomas Jund (@Sacripant) on CodePen.
Conclusion
Performance
Quelques aspects important concernant la performance, having no animation is better than animations that stutter :
- Contrairement aux animations CSS qui s’exécutent dans un tread séparé, JavaScript reste monothread. Il vaut mieux éviter qu’une animation JS tourne pendant d’autres processus (HTTP requests).
- L’animation d’éléments SVG se fait en manipulant le DOM. Ce qui en soit est une limite aux animations trop complexes. Bref, c’est bien pour animer un graphique ou un icône, pas pour les jeux vidéos.
- L’animation d’éléments possédant une couche alpha (opacité réduite ou flou gaussien) comme le cas des flocons dans la dernière version peut être un facteur important de ralentissement sur certains terminaux même si l’animation restera assez fluide.
Don’t use framework
Bien que d’écrire une animation avec RequestAnimationFrame
soit assez verbeux (ce qui m’a longuement rebuté), il n’est pas si compliqué, avec quelques briques de code assez simples, d’ajouter un peu de mouvement à nos sites web sans avoir besoin d’utiliser un framework volumineux.
Sources et Ressources
Aller plus loin
- Gain Motion Superpowers with requestAnimationFrame par Benjamin De Cock sur Medium
Timing function
- Emulating CSS Timing Functions with JavaScript par Ana tudor sur CSS-tricks
- Code des principales timing functions par Gaëtan Renaudeau
Math et animations
- A Quick Look Into The Math Of Animations With JavaScript par Christian Heilmann sur Smashing magazine.
- Dwitter, une plateforme de défi : écrire une animation JS canvas en moins de 140 caractères. Pour férus de math et fractales.