Aller au contenu
Me contacter
Tous les articles
5 min de lecture

GSAP dans React et Next.js : animer sans nuire aux performances ni à l’accessibilité

Comment j’anime ce portfolio avec GSAP dans Next.js tout en gardant des scores Lighthouse au maximum ou presque : contenu rendu côté serveur, nettoyage systématique avec gsap.matchMedia, respect de prefers-reduced-motion et animations limitées à transform et opacity.

  • GSAP
  • React
  • Next.js
  • Animation
  • Performance
  • Accessibilité

Les animations donnent du caractère à un site, mais elles ont mauvaise réputation : JavaScript en plus, décalages de mise en page, mouvements imposés à ceux qui les supportent mal. Ce portfolio utilise GSAP pour les apparitions au scroll, une frise dessinée au défilement et des halos qui suivent le pointeur. Il garde malgré tout des scores Lighthouse de 99 à 100 dans toutes les catégories, sur mobile comme sur desktop. Voici les règles qui le permettent.

Pourquoi GSAP plutôt que des transitions CSS ?

Pour un survol ou un changement d’état, une transition CSS suffit et ne coûte aucun JavaScript. GSAP devient utile dès qu’il faut enchaîner plusieurs animations sur une timeline, synchroniser un mouvement avec le défilement grâce à ScrollTrigger, ou suivre une valeur qui change en continu, comme la position du pointeur. Sur ce site, le CSS gère donc les survols, et GSAP les séquences d’apparition et les effets liés au scroll ou au pointeur.

Comment garder le contenu rendu côté serveur ?

GSAP manipule le DOM dans le navigateur, et ne peut donc tourner que dans un Client Component. La tentation est de marquer toute la section avec « use client », ce qui ferait basculer son contenu côté client. Je garde au contraire la frontière la plus basse possible : la section reste un Server Component, et seul un petit composant client chargé de l’animation enveloppe le contenu qu’il reçoit en children.

// Hero.tsx: a Server Component. Only RevealSection runs in the browser.
const revealSteps: RevealStep[] = [
  {target: 'badge', from: {opacity: 0, y: 14, duration: 0.5}},
  {target: 'heading', from: {y: 32, duration: 0.7}, position: '-=0.2'},
  {target: 'tagline', from: {opacity: 0, y: 18, duration: 0.55}, position: '-=0.4'},
];

export default function Hero() {
  return (
    <RevealSection steps={revealSteps} playOn="load">
      <h1 data-gsap="heading">…</h1>
      {/* … */}
    </RevealSection>
  );
}

Les étapes passent d’un Server Component à un Client Component, et doivent donc être sérialisables. Le composant n’accepte pour cette raison qu’un sous-ensemble des options de GSAP (opacity, x, y, duration, stagger, ease), sans fonction de rappel.

Nettoyer derrière soi avec gsap.matchMedia

Une animation créée dans un useEffect doit être annulée au démontage. Sans ça, elle continue de tourner sur des éléments retirés de la page, et en développement le Strict Mode de React, qui monte, démonte puis remonte chaque composant, la fait démarrer en double. gsap.matchMedia règle les deux problèmes : il enregistre tout ce qui est créé dans sa fonction, et revert l’annule d’un coup.

useEffect(() => {
  const mm = gsap.matchMedia();

  mm.add('(prefers-reduced-motion: no-preference)', () => {
    const tl = gsap.timeline({
      defaults: {ease: 'power3.out'},
      scrollTrigger: {trigger: sectionRef.current, start: 'top 78%'},
    });
    steps.forEach(({target, from, position}) => {
      tl.from(`[data-gsap="${target}"]`, from, position);
    });
  }, sectionRef); // selectors only match inside this section

  return () => mm.revert();
}, [steps, playOn]);

Le troisième argument de mm.add limite les sélecteurs à la section, si bien que deux sections qui utilisent les mêmes attributs data-gsap ne s’animent pas l’une l’autre. Le hook useGSAP du paquet @gsap/react offre le même nettoyage automatique ; matchMedia a l’avantage de traiter dans le même geste la préférence de mouvement, décrite plus bas.

Un composant d’apparition réutilisable

Plutôt que d’écrire un useEffect par section, toutes les apparitions passent par un seul composant, RevealSection. Chaque section marque ses éléments avec un attribut data-gsap et décrit ses étapes ; le composant construit la timeline, la déclenche au chargement ou à l’entrée dans l’écran, et la nettoie.

Un piège est à connaître. Les étapes figurent dans les dépendances du useEffect, et un tableau déclaré dans le corps du composant est recréé à chaque rendu, ce qui relance l’animation au moindre changement d’état. Elles se déclarent donc au niveau du module, une fois pour toutes, comme revealSteps dans l’exemple du Hero.

Comment respecter prefers-reduced-motion ?

Certaines personnes règlent leur système pour réduire les animations, parce que le mouvement leur cause des vertiges ou les empêche de se concentrer. La requête media prefers-reduced-motion transmet ce réglage à la page. Sur ce site, toutes les animations sont enregistrées sous la condition (prefers-reduced-motion: no-preference) : avec la préférence activée, aucune ne démarre.

Le contenu ne dépend jamais de l’animation pour être visible. Il est présent dans le HTML, à sa place définitive, et GSAP ne fait que l’amener depuis un état de départ. Sans animation, la frise du parcours est simplement affichée entièrement dessinée, avec toutes ses étapes allumées. L’effet de pointeur ajoute une condition de plus, (pointer: fine) and (min-width: 1024px), qui le réserve aux écrans larges avec une souris.

Comment ne pas pénaliser les Core Web Vitals ?

  • N’animer que transform et opacity, que le navigateur traite sans recalculer la mise en page. La ligne de la frise se dessine ainsi avec scaleY, et non en animant sa hauteur.
  • Ne jamais réserver ni libérer de place pendant une animation : les éléments occupent leur espace dès le premier rendu et ne font que glisser ou apparaître, si bien qu’aucun décalage de mise en page (CLS) ne survient.
  • Ménager l’élément le plus grand de l’écran d’accueil, généralement retenu pour le LCP. Dans le Hero, le titre principal glisse sans fondu, quand le badge et le sous-titre apparaissent en fondu : il est peint dès le premier affichage.
  • Garder des animations courtes au chargement, entre 0,45 et 0,9 seconde ici, pour que la page paraisse prête sans attendre.
  • Réutiliser les tweens pour les mouvements continus : gsap.quickTo crée une fois pour toutes la fonction qui suit le pointeur, au lieu d’un nouveau tween à chaque mouvement de souris.
  • Jouer une seule fois les apparitions au scroll (once: true), sans les relancer à chaque passage.
// The career line is drawn with a transform, tied to the scroll position
gsap.fromTo(progress, {scaleY: 0}, {
  scaleY: 1,
  ease: 'none',
  scrollTrigger: {trigger: scope, start: 'top 65%', end: 'bottom 65%', scrub: true},
});

Ce que mesure Lighthouse

Avec ces règles, Lighthouse donne à ce portfolio des scores de 99 à 100 en performance, en accessibilité, en bonnes pratiques et en SEO, sur mobile comme sur desktop. GSAP pèse dans le JavaScript chargé, mais il ne bloque ni l’affichage du contenu, rendu côté serveur, ni sa stabilité. Le coût de la bibliothèque reste ainsi sans effet visible sur les indicateurs qui comptent.

Animer un site Next.js avec GSAP ne condamne ni les performances ni l’accessibilité, à condition de garder le contenu côté serveur, de nettoyer chaque animation, de respecter la préférence de mouvement et de n’animer que ce que le navigateur sait déplacer sans recalcul. Les animations restent alors ce qu’elles doivent être, une couche de finition sur un contenu qui s’en passe très bien. Pour voir le résultat, il suffit de parcourir l’accueil de ce site ; pour en discuter, écrivez-moi.