Un bon scroll, c’est quatre couches, pas une librairie
L’erreur de départ, c’est de croire qu’on installe « le smooth scroll » et que le site devient premium. Un scroll de niveau studio, c’est quatre mécaniques indépendantes qu’on empile dans cet ordre :
- L’inertie : le défilement lui-même est lissé, la page suit la molette avec une légère traîne.
- La révélation : les éléments apparaissent quand ils entrent dans le champ.
- L’épinglage : une section reste fixe pendant que son contenu progresse.
- Le scrub : une animation, une vidéo ou une scène est pilotée image par image par la position de scroll.
Les sites qui impressionnent utilisent les quatre. Les sites qui donnent la nausée en utilisent trois au hasard, mal réglées, sur la même page.
Trois sites, trois écoles
Avant de coder, regarde ce que font ceux qui réussissent. Ces trois-là ont été inspectés directement, pas décrits de mémoire.
landonorris.com : Webflow, plus une couche pilotée par attributs
Un site Webflow (attributs data-wf-site, assets sur le CDN Webflow) sur lequel une agence a greffé une vraie couche technique. Lenis est injecté via un simple embed contenant sa feuille de style (html.lenis, .lenis-smooth [data-lenis-prevent]). Et surtout, une vingtaine de <canvas> Rive pilotés par des attributs HTML : data-rive-scrolltrigger-target, data-rive-scrolltrigger-start, data-rive-scrolltrigger-end, data-rive-fit, data-rive-input.
Le pattern à voler est là : aucune animation n’est écrite dans le markup. Un seul script scanne le DOM, lit les attributs et branche ScrollTrigger. Un intégrateur peut ajouter une animation depuis Webflow sans toucher une ligne de JavaScript.
jobs.netflix.com : la sobriété qui tient à l’échelle
Next.js en App Router, avec un composant LenisProvider qui enveloppe l’application. Pas de 3D, pas d’effet spectaculaire : un site de recrutement qui doit rester utilisable. Le détail qui trahit le travail sérieux : le panneau de recherche porte data-lenis-prevent="true". Ce panneau a son propre scroll interne, donc il est exclu du lissage global. Sans cet attribut, la molette dans le panneau ferait bouger la page derrière. C’est exactement le genre de détail qui sépare une intégration propre d’un copier-coller de documentation.
rockstargames.com : le scroll déclaratif en React
Le HTML servi fait 3,5 Ko. Tout le site est assemblé en micro-frontends chargés dynamiquement (quinze modules indépendants, versionnés séparément, servis depuis un CDN). Dans les dépendances partagées déclarées : React, React Router et framer-motion 12.42.2. Pas de GSAP, pas de Lenis. Leurs animations de scroll sont écrites en React, dans les composants, avec useScroll et useTransform. C’est une approche complètement différente des deux autres, et parfaitement valide : quand le site est une application React à plusieurs équipes, une animation déclarative dans le composant se maintient mieux qu’un script global qui scanne le DOM.
Retiens la règle : site vitrine ou Webflow, prends GSAP plus Lenis. Application React, prends Framer Motion. Mélanger les deux sur un même projet, c’est deux boucles d’animation concurrentes et des saccades garanties.
Couche 1 : l’inertie
C’est la couche la plus visible et la plus vite ratée. Le principe : le navigateur ne défile plus, on intercepte l’événement et on interpole la position à chaque image.
import Lenis from 'lenis'
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
const lenis = new Lenis({
duration: 1.1, // 0.8 a 1.2. Au-dela, le site parait mou.
smoothWheel: true,
smoothTouch: false, // on laisse le scroll natif au doigt
})
// Une seule horloge pour tout le site : celle de GSAP.
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)
Ces cinq lignes de branchement sont le cœur du sujet. Si tu fais tourner Lenis dans sa propre boucle requestAnimationFrame et GSAP dans la sienne, les deux se décalent d’une image et tout ce qui est épinglé se met à trembler. Une seule horloge, toujours.
Les quatre erreurs qui reviennent :
- Une durée trop longue. Au-delà de 1,2, le site ne paraît pas fluide, il paraît lent à répondre. La sensation de qualité vient de la réactivité, pas de la traîne.
- Cumuler avec
scroll-behavior: smoothen CSS. Les deux se battent, les ancres deviennent imprévisibles. Retire le CSS. - Oublier les panneaux à scroll interne. Modales, menus, listes déroulantes : chacun reçoit
data-lenis-prevent, comme sur le site de recrutement Netflix. - Forcer l’inertie sur mobile. Le scroll tactile natif est déjà parfaitement lié au doigt. Le lisser crée un décalage perçu immédiatement comme un bug.
Couche 2 : les révélations
Un seul fichier, aucune animation dans les composants. On déclare l’intention en HTML, le script s’occupe du reste :
// Une passe, tous les elements marques du site
ScrollTrigger.batch('[data-anim="fade"]', {
start: 'top 85%',
once: true,
onEnter: (els) => gsap.to(els, {
opacity: 1,
y: 0,
duration: 0.8,
ease: 'expo.out',
stagger: 0.06,
}),
})
Les valeurs qui marchent, après beaucoup d’essais : un déplacement de 12 à 24 pixels (au-delà, ça saute aux yeux), une durée de 0,6 à 0,9 seconde, un ease en expo.out ou power3.out, un décalage de 0,04 à 0,08 seconde entre les éléments d’un groupe, et un déclenchement à top 85% pour que l’animation soit déjà commencée quand l’œil arrive dessus.
Le once: true n’est pas un détail. Une animation qui rejoue à chaque remontée transforme la lecture en attraction foraine. On révèle une fois, et on n’anime que les titres et les intros de section, jamais chaque paragraphe.
Dernier point : recalcule les positions quand les polices sont chargées, sinon tous tes déclencheurs sont calés sur une mise en page qui n’existe plus.
document.fonts.ready.then(() => ScrollTrigger.refresh())
Couche 3 : l’épinglage
Une section qui reste fixe pendant que son contenu avance. C’est ce qui donne l’impression que le site est un film plutôt qu’une page. Deux voies : position: sticky en CSS, gratuit et increvable, ou le pin de ScrollTrigger, plus puissant mais qui modifie le DOM.
La règle simple : si tu veux juste qu’un élément colle, utilise sticky. Si tu dois lier la progression du scroll à une animation, utilise pin avec scrub. Le cas classique, la section horizontale :
const panels = gsap.utils.toArray('.panel')
gsap.to(panels, {
xPercent: -100 * (panels.length - 1),
ease: 'none',
scrollTrigger: {
trigger: '.horizontal',
pin: true,
scrub: 0.5, // la latence : c'est elle qui lisse
end: () => '+=' + window.innerWidth * panels.length,
invalidateOnRefresh: true,
},
})
scrub: 0.5 plutôt que scrub: true : l’animation rattrape la position de scroll avec un demi-seconde de retard, ce qui absorbe les à-coups de la molette. C’est un réglage à une valeur qui change tout le ressenti.
Sur mobile, désactive l’épinglage ou réduis-le drastiquement. Une section horizontale épinglée sur 400 % de hauteur, sur un téléphone, donne surtout l’impression que la page est bloquée.
Couche 4 : le scrub
La couche qui impressionne vraiment : une vidéo ou une animation dont chaque image correspond à une position de scroll. Trois techniques, par ordre de rentabilité.
Le scrub vidéo
Une balise <video> en pause dont on pilote le currentTime. Beaucoup plus léger qu’une séquence de 300 PNG, et c’est ce qu’utilisent les grandes pages produit aujourd’hui.
const video = document.querySelector('[data-scrub-video]')
video.pause()
ScrollTrigger.create({
trigger: '.sequence',
start: 'top top',
end: '+=200%',
pin: true,
scrub: 0.3,
onUpdate: (self) => {
if (video.duration) video.currentTime = self.progress * video.duration
},
})
Le piège, c’est l’encodage. Une vidéo normale n’a qu’une image-clé toutes les deux secondes : le déplacement dans le temps saccade parce que le décodeur doit reconstruire chaque image. Il faut réencoder avec une image-clé partout :
ffmpeg -i source.mp4 -an -vf "scale=1280:-2" -c:v libx264 -crf 26 -g 1
-movflags +faststart scrub.mp4
Le -g 1 force chaque image en image-clé. Le fichier grossit d’environ 40 %, mais le scrub devient parfaitement fluide. Sans lui, l’effet est inutilisable.
Rive
Pour du motion vectoriel, c’est supérieur à tout le reste : quelques dizaines de kilo-octets, net à toutes les résolutions, et une machine d’états qu’on pilote depuis le code. C’est le choix fait sur le site de Lando Norris, avec la progression du scroll envoyée directement dans les entrées de l’animation. Un designer travaille dans l’éditeur Rive, le développeur ne fait que brancher.
La scène 3D
Three.js piloté au scroll, c’est le niveau au-dessus, et un autre budget. À ne considérer que si l’objet 3D est le produit lui-même.
La variante React : le scroll déclaratif
Si le projet est une application React, pas besoin de scanner le DOM. La progression du scroll devient une valeur réactive :
const ref = useRef(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
})
const y = useTransform(scrollYProgress, [0, 1], ['0%', '-25%'])
const smooth = useSpring(y, { stiffness: 120, damping: 20, mass: 0.4 })
return <motion.div ref={ref} style={{ y: smooth }} />
Le useSpring autour du useTransform joue le rôle du scrub de GSAP : il amortit le mouvement au lieu de le coller image par image à la molette. Sans lui, le résultat paraît nerveux et bon marché.
Les règles de performance
Une animation au scroll dispose de 16 millisecondes par image. Ce qui la fait sortir du budget :
- N’anime que
transformetopacity. Animertop,left,width,heightoumarginforce un recalcul de mise en page à chaque image. C’est la cause numéro un des saccades. will-change: transformavec parcimonie, et jamais sur des dizaines d’éléments : chaque couche promue consomme de la mémoire vidéo.- Pas de lecture du DOM dans une boucle. Un
getBoundingClientRect()appelé à chaque image annule tout le bénéfice. ScrollTrigger met les positions en cache, laisse-le faire. - Un seul observateur global plutôt qu’un par composant.
- Vérifie sur un vrai téléphone milieu de gamme, pas sur ton portable. C’est là que se jouent les avis.
L’accessibilité, la partie qu’on saute toujours
Pour une partie des visiteurs, le mouvement provoque de vrais troubles. Le système le signale, il suffit d’écouter :
const mm = gsap.matchMedia()
mm.add('(prefers-reduced-motion: no-preference)', () => {
// toutes les animations vivent ici
})
mm.add('(prefers-reduced-motion: reduce)', () => {
lenis.destroy()
gsap.set('[data-anim]', { opacity: 1, y: 0, clearProps: 'transform' })
})
Le réflexe important : la version réduite affiche l’état final immédiatement. Elle ne désactive pas l’animation en laissant les éléments invisibles, ce qui est l’un des bugs les plus fréquents et les plus graves : un site entièrement vide pour l’utilisateur. Vérifie aussi que la navigation au clavier fonctionne dans les sections épinglées, et que les ancres tombent au bon endroit après un rechargement.
Ce que le CSS sait déjà faire tout seul
Pour les révélations simples, le JavaScript n’est déjà plus obligatoire :
@keyframes reveal {
from { opacity: 0; translate: 0 24px; }
to { opacity: 1; translate: 0 0; }
}
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 10% cover 35%;
}
Animé par le compositeur, sans une ligne de JS, sans coût de boucle. Le support est encore partiel : on l’utilise en enrichissement, avec un @supports et un état final visible par défaut. Pour un site où le scroll n’est pas le sujet principal, c’est souvent tout ce dont tu as besoin, et ça évite de charger 50 Ko de librairie pour trois fondus.
Brancher le prompt sur Claude Code ou Codex
Le prompt en haut de cette fiche n’est pas fait pour être collé une fois puis oublié. C’est un fichier de contraintes : il vit à la racine du projet et l’agent le relit à chaque session. D’un agent à l’autre, seul le nom du fichier change.
- Claude Code :
CLAUDE.mdà la racine du dépôt. - Codex :
AGENTS.md, même contenu au caractère près. C’est la convention qu’il va chercher. - Les deux sur le même projet : garde
AGENTS.mdcomme source unique et réduisCLAUDE.mdà une ligne d’import,@AGENTS.md. Une seule version à maintenir, pas deux qui divergent au bout d’une semaine. - En conversation simple, sans agent (Claude ou ChatGPT dans le navigateur) : colle le prompt, puis envoie le fichier de la section à traiter. Sans le contexte du dépôt, demande le code d’un seul bloc à la fois.
Deux réflexes valables quel que soit l’agent. Le premier : une couche à la fois, avec validation entre chaque, jamais les quatre d’un coup. Le second : fais-lui décrire en trois puces ce qu’il s’apprête à écrire avant qu’il écrive la moindre ligne. Corriger une description coûte dix secondes, corriger trois cents lignes en coûte trente minutes.
Une limite à connaître : sans outil de capture branché, aucun des deux ne voit son propre rendu. Les valeurs chiffrées de ce guide (déclenchement à 85 %, 0,8 seconde, expo.out, scrub: 0.5) servent exactement à ça : elles remplacent l’œil tant que tu n’as pas mis une boucle visuelle en place.
La checklist avant de livrer
- Une seule boucle d’animation, celle de GSAP, avec Lenis branché dessus.
data-lenis-preventsur chaque zone à scroll interne.ScrollTrigger.refresh()après le chargement des polices et des images.- Révélations en
once: true, réservées aux titres et intros. - Épinglages désactivés ou raccourcis sous 768 pixels.
- Vidéos de scrub réencodées en
-g 1. - Branche
prefers-reduced-motiontestée pour de vrai, en activant l’option système. - Test sur téléphone milieu de gamme, pas seulement au simulateur.
- Ancres, retour arrière et restauration de position vérifiés.
Le scroll est la seule interaction que 100 % de tes visiteurs vont utiliser. C’est aussi la seule qu’ils remarquent quand elle est ratée. Trois jours de réglage dessus se voient plus qu’un mois passé sur une fonctionnalité que personne n’ouvre.