README · Scroll-driven Frame Animation
Technique d'animation web pilotée par le scroll : une séquence d'images (frames) est jouée
image par image en fonction de la position de défilement. Aucun framework — HTML, CSS, Canvas 2D.
HTML5
CSS3
Canvas 2D
Vanilla JS
240 frames
Scroll-synced
Principe fondamental
Qu'est-ce que le scroll-driven frame animation ?
C'est la technique utilisée sur les pages produit Apple (iPhone, AirPods) : une vidéo ou séquence
d'images est décomposée en frames individuelles .
La position du scroll remplace le temps — au lieu de jouer l'animation à vitesse constante,
c'est l'utilisateur qui la contrôle en faisant défiler la page.
1
Découpage — l'animation source (GIF, vidéo, After Effects) est exportée en JPG/WebP numérotés.
2
Préchargement — toutes les frames sont chargées en mémoire via new Image() au démarrage.
3
Mapping — scrollY / maxScroll donne un ratio 0→1, converti en index de frame.
4
Rendu — la frame correspondante est dessinée sur un <canvas> fixe via ctx.drawImage().
Structure HTML
Squelette de la page
Le canvas est déclaré avant tout contenu — il sera positionné fixe en CSS et servira de fond à toute la page.
<!-- 1. Canvas de fond -->
<canvas id ="canvas" ></canvas>
<!-- 2. Navigation fixe par-dessus -->
<header class ="nav" > ...</header>
<!-- 3. Sections de contenu (flux normal) -->
<section id ="hero" > ...</section>
<section id ="s1" > ...</section>
<section id ="final" > ...</section>
<!-- 4. Script en fin de body -->
<script src ="app.js" ></script>
Pas besoin de defer en fin de body : le DOM est déjà parsé.
CSS · z-index stratification
Les trois couches
┌─────────────────────────────────────┐
z-index: 100 header.nav
│ position: fixed · backdrop-filter │
├─────────────────────────────────────┤
z-index: 10 sections (flux normal)
│ position: relative · glass cards │
├─────────────────────────────────────┤
z-index: 0 canvas
│ position: fixed · inset: 0 │
└─────────────────────────────────────┘
#canvas {
position : fixed ;
inset : 0 ; /* top/right/bottom/left: 0 */
z-index : 0 ;
width : 100% ;
height : 100% ;
}
.section {
position : relative ;
z-index : 10 ; /* passe devant le canvas */
min-height : 120vh ; /* crée la hauteur de scroll */
}
JavaScript — cœur du mécanisme
scroll → frame index → drawImage
// ── 1. Préchargement ──────────────────────────────
const TOTAL = 240 ;
const imgs = new Array (TOTAL ).fill (null );
for (let i = 0 ; i < TOTAL ; i++) {
const frameNum = TOTAL - i; // i=0 → 240 (éclatée), i=239 → 001 (assemblée)
const img = new Image ();
img .src = `pict/frame-${ String (frameNum ).padStart (3 , '0' )}.jpg` ;
img .onload = () => { imgs [i] = img ; };
}
// ── 2. Scroll → ratio → index ─────────────────────
function onScroll () {
const maxScroll = document .documentElement .scrollHeight
- window .innerHeight ;
const progress = window .scrollY / maxScroll ; // 0 → 1
const index = Math .round (progress * (TOTAL - 1 ));
draw (index );
}
// ── 3. Dessin cover-fit sur canvas ────────────────
function draw (index ) {
const img = imgs [index ];
if (!img ) return ;
const scale = Math .max (cw / img .width , ch / img .height );
ctx .clearRect (0 , 0 , cw , ch );
ctx .drawImage (img ,
(cw - img .width * scale ) / 2 ,
(ch - img .height * scale ) / 2 ,
img .width * scale ,
img .height * scale
);
}
Performance · CPU
requestAnimationFrame
Ne jamais appeler draw() directement dans l'événement scroll — il peut se déclencher des dizaines de fois par seconde.
let rafPending = false ;
function onScroll () {
if (!rafPending ) {
rafPending = true ;
requestAnimationFrame (update );
}
}
function update () {
rafPending = false ;
// calcul index + draw ici
}
Le flag rafPending garantit au plus 1 rendu par frame d'affichage (60 fps max).
Performance · Réseau
Chargement des frames
Charger en ordre frame 1 en premier (visible immédiatement avant scroll)
Utiliser JPG progressif ou WebP pour réduire le poids
240 frames × ~15 Ko = ~3,6 Mo total (acceptable en local / CDN)
img.decoding = 'async' évite de bloquer le thread principal
En production : lazy-load les frames distantes par tranches de 30
const img = new Image ();
img .decoding = 'async' ;
img .src = `pict/frame-${ n }.jpg` ;
Performance · Rendu
Canvas vs <img>
Canvas 2D — contrôle total (cover-fit, filtres, overlays). Choix retenu.
<img src="..."> — plus simple, mais pas de cover-fit natif.
CSS background-image — fonctionne, mais génère un repaint complet.
WebGL / Three.js — GPU, idéal pour effets de shader, complexité accrue.
Toujours dimensionner le canvas via JS (canvas.width = window.innerWidth), pas CSS — sinon le bitmap est flou.
Formules de mapping
Scroll → frame · sens direct et inversé
Selon le sens de l'animation voulue :
// progress : 0 (haut) → 1 (bas)
const progress = window .scrollY
/ (document .documentElement .scrollHeight
- window .innerHeight );
// ▶ Sens normal : frame 001 en haut, frame 240 en bas
const index = Math .round (progress * (TOTAL - 1 ));
// → imgs[0] = frame 001, imgs[239] = frame 240
// ◀ Sens inversé : frame 240 en haut, frame 001 en bas
// (charger imgs[i] = frame TOTAL-i lors du preload)
const frameNum = TOTAL - i; // i=0 → 240, i=239 → 1
Dans ce projet : l'image éclatée est en haut (frame 240), l'image assemblée apparaît en bas (frame 001).
CSS · Glass morphism
Sections lisibles sur animation
Les sections de texte doivent rester lisibles quelle que soit la frame affichée. La recette :
.glass-card {
background : rgba (4, 7, 16, 0.75 );
backdrop-filter : blur (24px ) saturate (130% );
border : 1px solid rgba (255,255,255, 0.07 );
border-radius : 22px ;
box-shadow :
0 24px 64px rgba (0,0,0, 0.55 ),
inset 0 1px 0 rgba (255,255,255, 0.045 );
}
backdrop-filter: blur() floute l'animation derrière la carte
Fond semi-transparent : contraste sans masquer l'image
Le box-shadow inset crée une bordure lumineuse subtile
Workflow · Préparation des assets
Exporter et nommer les frames
Depuis un GIF
Upload le GIF → Split to frames
Télécharger les frames nommées frame-001.jpg
Placer dans pict/
Depuis une vidéo (ffmpeg)
ffmpeg -i anim.mp4 \
-vf "fps=30,scale=1920:-1" \
-q:v 3 \
pict/frame-%03d.jpg
Depuis After Effects
Composition → Rendu → séquence JPG
Nommage automatique frame_0001.jpg
Adapter le padStart au format choisi
Recommandations
JPG qualité 75–80 : bon compromis poids/qualité
WebP : 30 % plus léger, support moderne
Résolution max utile : 1920×1080 (inutile de dépasser)
60–120 frames suffisent pour une animation fluide
CSS natif (2024+)
CSS Scroll-driven Animations
Les navigateurs modernes supportent les animations pilotées par le scroll en CSS pur, sans JS :
@keyframes fade-in {
from { opacity: 0 ; }
to { opacity: 1 ; }
}
.card {
animation : fade-in linear;
animation-timeline : scroll ();
animation-range : entry 0% exit 100% ;
}
Parfait pour des transitions CSS (opacity, transform). Pas adapté au frame-scrubbing d'images.
Alternative · Vidéo
Video scrubbing
Au lieu de frames individuelles, on peut scrubber une balise <video> :
const video = document .querySelector ('video' );
function onScroll () {
const p = window .scrollY / maxScroll ;
video .currentTime = p * video .duration ;
}
Un seul fichier à charger (vs 240 JPG)
Mais le scrubbing vidéo peut être saccadé sur Safari/iOS
Encoder en HEVC avec keyframes fréquentes améliore la fluidité
Ce projet · demo-anim/
Structure des fichiers
demo-anim/
├── index.html ← structure HTML · canvas + sections + nav
├── style.css ← canvas fixed · glassmorphism · z-index stratification
├── app.js ← scroll handler · preload · drawImage cover-fit
└── pict/
├── frame-001.jpg ← image assemblée (fin du scroll)
├── frame-002.jpg
├── ...
└── frame-240.jpg ← image éclatée (début de page)
Aucune dépendance externe. Pas de Three.js, pas de GSAP, pas de librairie de scroll.
Le mécanisme complet tient en ~50 lignes de JS vanilla .
README · Scroll-driven Frame Animation · JoYz.pro
HTML · CSS · Canvas 2D · Vanilla JS · 2026