Dans ce tutoriel je vous propose de découvrir ensemble comment animer vos composants React.
La structure de React est assez peu propice à la création d'animations car avec le virtual-dom on veut que notre structure reflète notre état. Si on introduit un effet de disparition un élément peut rester visible un petit moment après sa disparition dans l'état.
Plusieurs approches possibles
Utiliser une librairie tiers
React dispose de nombreuses librairies pour créer des animations. Elles sont très intéressantes mais sont, en général, très lourdes et assez inadaptées pour des cas simples.
- Framer motion est la librairie la plus complète (mais aussi la plus lourde).
- React spring, utilise des hooks et des fonctions d'animations basées sur des ressorts.
- React transition group, permet d'avoir une base pour créer des animations en CSS.
Aussi, pour des cas simples il est parfois plus pertinent de créer un simple composant fait maison.
Alterner une classe CSS
La première idée consiste à utiliser une simple classe pour gérer l'animation.
.fade {
transition: opacity 1s;
}
.fade.out {
opacity: 0;
}
Et on l'applique ou la retire au besoin.
function Fade({ visible, children }) {
const className = `fade ${visible ? "" : "out"}`.trim();
return <div className={className}>{children}</div>;
}
L'inconvénient est que l'élément reste présent dans le DOM (ce qui peut poser problème dans un display grid) et dans le virtual DOM (ce qui va générer des rendus inutiles).
On peut alors rajouter un état à notre composant pour ne pas rendre le composant enfant.
function Fade({ visible, children, duration = 300 }) {
const [showChildren, setShowChildren] = useState(visible);
useEffect(() => {
if (visible) {
setShowChildren(true);
} else {
// On laisse l'animation se dérouler avant de le masquer
const timer = window.setTimeout(() => {
setShowChildren(false);
}, duration);
return () => {
clearTimeout(timer);
};
}
});
const className = `fade ${visible ? "" : "out"}`.trim();
return <div className={className}>{showChildren && children}</div>;
}
Cela permet de résoudre le problème de l'enfant qui reste présent dans le virtual DOM mais une <div>
vide restera présente dans le DOM. On peut aussi choisir de ne pas rendre cette <div>
si l'élément n'est plus visible mais on perdre alors l'effet d'apparition.
Pour gérer l'effet d'apparition il faut créer l'élément avec la classe fade
et out
puis demander au navigateur de retirer la classe après le premier re-paint.
Et c'est là que les choses se compliquent ! Il serait très simple de gérer les choses de manière impératives.
element.classList.add("fade");
element.classList.add("out");
element.offsetHeight; // On force le repaint
element.classList.remove("out"); // Fade in !
Afin de simplifier la logique, nous allons utiliser le principe des machines à états et créer 4 états :
const VISIBLE = 1; // L'élément est visible
const HIDDEN = 2; // L'élément est masqué
const ENTERING = 3; // L'élément est animé en entrée
const LEAVING = 4; // L'élément est animé en sortie
Et nous allons ensuite les utiliser pour savoir quelle classe appliquer. On utilisera aussi une ref
pour mémoriser l'état de l'enfant au moment de son retrait.
export function Fade({
visible,
children,
duration = 300,
animateEnter = false,
}) {
const childRef = useRef(children);
const [state, setState] = useState(
visible ? (animateEnter ? ENTERING : VISIBLE) : HIDDEN
);
if (visible) {
childRef.current = children;
}
useEffect(() => {
if (!visible) {
setState(LEAVING);
} else {
setState((s) => (s === HIDDEN ? ENTERING : VISIBLE));
}
}, [visible]);
useEffect(() => {
if (state === LEAVING) {
const timer = setTimeout(() => {
setState(HIDDEN);
}, duration);
return () => {
clearTimeout(timer);
};
} else if (state === ENTERING) {
document.body.offsetHeight; // force repaint
setState(VISIBLE);
}
}, [state]);
if (state === HIDDEN) {
return null;
}
let className = "fade out";
if (state === VISIBLE) {
className = "fade";
}
return <div className={className}>{childRef.current}</div>;
}
Utiliser l'attribut style
Afin de ne pas multiplier les règles CSS, on peut générer le style à la volée.
import React, { useEffect, useRef, useState } from "react";
const VISIBLE = 1;
const HIDDEN = 2;
const ENTERING = 3;
const LEAVING = 4;
/**
* @param {boolean} visible
* @param {React.ReactNode} children
* @param {number} duration en ms
* @param {boolean} animateEnter Anime l'arrivée de l'élément
* @param {{opacity?: number, x?: number, y?: number, z?: number}} from
**/
export function Fade({
visible,
children,
duration = 300,
animateEnter = false,
from = { opacity: 0 },
}) {
const childRef = useRef(children);
const [state, setState] = useState(
visible ? (animateEnter ? ENTERING : VISIBLE) : HIDDEN
);
if (visible) {
childRef.current = children;
}
useEffect(() => {
if (!visible) {
setState(LEAVING);
} else {
setState((s) => (s === HIDDEN ? ENTERING : VISIBLE));
}
}, [visible]);
useEffect(() => {
if (state === LEAVING) {
const timer = setTimeout(() => {
setState(HIDDEN);
}, duration);
return () => {
clearTimeout(timer);
};
} else if (state === ENTERING) {
document.body.offsetHeight;
setState(VISIBLE);
}
}, [state]);
if (state === HIDDEN) {
return null;
}
let style = {
transitionDuration: `${duration}ms`,
transitionProperty: "opacity transform",
};
if (state !== VISIBLE) {
if (from.opacity !== undefined) {
style.opacity = from.opacity;
}
style.transform = `translate3d(${from.x ?? 0}px, ${from.y ?? 0}px, ${
from.z ?? 0
}px)`;
}
return <div style={style}>{childRef.current}</div>;
}
Et voila ! Vous pouvez utiliser votre composant pour animer vos éléments.
<Fade from={{opacity: 0, x: 10}} visible={data !== null}>
<List data={data}>
</Fade>
Pour aller plus loin
On pourrait pousser cette approche plus loin en détectant automatiquement la disparition d'un composant enfant. Cela implique d'ajouter plus de logique pour mémoriser l'ensemble des composants enfants rendus et leur état.