Ce portfolio utilise GSAP pour toutes ses animations : titres qui montent lettre par lettre, carte de la Normandie qui se dessine au défilement, textes qui s'éclairent mot à mot. Voici ce que cette refonte m'a appris, notamment sur ce qui se passe mal quand on mélange animation et rendu serveur.
Démarrer au bon endroit
GSAP manipule le DOM : rien ne doit tourner côté serveur. Dans Nuxt, tout part donc de onMounted, et tout doit être nettoyé en quittant la page, sinon les ScrollTrigger s'accumulent à chaque navigation.
gsap.matchMedia() règle les deux problèmes à la fois, et il gère aussi l'accessibilité et le responsive :
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
const root = ref<HTMLElement>()
let media: gsap.MatchMedia | undefined
onMounted(() => {
gsap.registerPlugin(ScrollTrigger)
media = gsap.matchMedia()
media.add('(prefers-reduced-motion: no-preference)', () => {
const q = gsap.utils.selector(root.value)
gsap.from(q('.title'), { y: 24, opacity: 0, duration: 0.8 })
}, root.value)
})
onBeforeUnmount(() => media?.revert())
Tout ce qui est créé dans le callback est annulé par revert(). Et les personnes qui ont demandé à réduire les animations sur leur appareil voient simplement la page, sans rien de plus à coder.
Le clignotement au chargement
C'est le premier problème qu'on rencontre. Le serveur envoie la page complète, le navigateur l'affiche, puis le JavaScript arrive et gsap.from() remet les éléments à leur état de départ. Résultat : le titre apparaît, disparaît, puis s'anime.
La solution est de le cacher en CSS jusqu'au démarrage de l'animation. Mais attention : si le script ne se charge jamais, votre titre reste invisible pour toujours. Il faut donc un filet de sécurité :
@media (prefers-reduced-motion: no-preference) {
.hero:not(.hero-ready) .hero-in {
opacity: 0;
animation: hero-fallback 0.3s 2.5s forwards;
}
}
@keyframes hero-fallback {
to { opacity: 1; }
}
Le script ajoute hero-ready juste avant de lancer l'animation. S'il ne se lance pas, le contenu apparaît quand même après 2,5 secondes. Le texte reste dans le HTML, donc lisible par les moteurs de recherche.
Gardez ce compromis en tête : un titre caché jusqu'à l'exécution du JavaScript retarde son affichage sur connexion lente, ce qui peut pénaliser le LCP. Réservez ce traitement à ce qui le mérite, et surveillez vos mesures en production.
Le rendu paresseux de GSAP
Détail appris à mes dépens : par défaut, GSAP applique les valeurs de départ au prochain cycle de rendu. Pour une entrée d'animation, je préfère les appliquer tout de suite avec lazy: false, afin d'écarter tout risque d'une image affichée à l'état final :
gsap.timeline({ defaults: { ease: 'expo.out', lazy: false } })
Sticky plutôt que pin
Pour accrocher une section pendant le défilement, le réflexe est pin: true. Ça marche, mais GSAP ajoute un élément autour de la section et la passe en position: fixed, ce qui peut casser avec certains parents.
CSS sait déjà le faire. La section garde son comportement naturel, et ScrollTrigger ne sert plus qu'à piloter l'animation :
<div class="track" style="height: 260vh">
<div class="sticky top-0 h-screen">
<!-- le contenu accroché -->
</div>
</div>
Un piège à connaître : une propriété transform sur un parent peut gêner ces positionnements, y compris une simple animation d'entrée posée sur un conteneur. Et overflow: hidden sur un parent casse sticky, alors que overflow-x: clip ne pose pas ce problème.
Découper le texte sans le perdre
SplitText, devenu gratuit, découpe un titre en lettres et sait créer le masque nécessaire pour les faire monter :
const title = SplitText.create(el, { type: 'words,chars', mask: 'chars' })
gsap.from(title.chars, { yPercent: 110, stagger: 0.03 })
Deux surprises :
- Les mots se coupent n'importe où. Une fois en lettres, « Contactez-moi » peut passer à la ligne au milieu. Le
type: 'words,chars'aide, etwhite-space: nowraprègle le cas des titres courts. - Le texte en dégradé casse. Un
background-clip: textne survit pas à des lettres transformées séparément. Pour ces titres, j'anime le bloc entier avec unclip-pathplutôt que lettre par lettre.
Dessiner une carte au défilement
La technique classique pour tracer un SVG utilise pathLength="1" et stroke-dashoffset. Elle cesse de fonctionner si vous ajoutez vector-effect: non-scaling-stroke : les tirets sont alors calculés à l'échelle de l'écran, et votre trait apparaît en pointillés. Pour garder une épaisseur constante pendant un zoom, calculez plutôt stroke-width selon le niveau de zoom.
Tester, vraiment
Une dernière leçon. Un navigateur en arrière-plan ne déclenche pas requestAnimationFrame, donc GSAP n'y avance pas : vos mesures y sont fausses, et une animation peut sembler bloquée alors qu'elle fonctionne. Vérifiez aussi la largeur de la page après chaque effet : un élément qui sort de l'écran pendant un zoom crée vite un défilement horizontal, invisible tant qu'on ne regarde que le début de l'animation.
En résumé
- Centralisez tout dans
gsap.matchMedia()pour le nettoyage et l'accessibilité. - Cachez peu, et toujours avec un repli sans JavaScript.
- Préférez
stickyàpinquand c'est possible. - Mesurez en production : une belle animation ne vaut pas un site lent.
L'animation doit servir la lecture. Celles que j'ai gardées ici sont celles qui guident l'œil, et j'en ai retiré plusieurs en cours de route parce qu'elles n'apportaient rien.