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
MappingscrollY / 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.