Une page produit Apple ne repose sur aucune librairie magique. En ouvrant les bundles JavaScript livrés en production, on trouve trois techniques distinctes, empilables, et toutes reproductibles. Ce qui suit vient de l’inspection directe du code d’apple.com, pas d’articles de seconde main.
Ce qu’il y a vraiment dans le code
iPhone 17 Pro : un moteur 3D avec ses propres shaders
Le bundle main.built.js pèse 680 Ko et contient une classe ProductViewerWebGL en singleton, avec son chargeur de scène et des réactions MobX. Le plus parlant, ce sont les shaders GLSL écrits à la main :
uniform float focalPoint;
uniform float near;
uniform float far;
layout(location = 0) out vec4 gColor;
layout(location = 1) out vec4 gDepth;
Deux cibles de rendu, une pour la couleur, une pour la profondeur, plus un point de focale : Apple applique une profondeur de champ en post-traitement sur le modèle du téléphone. Le sélecteur de coloris n’est pas un carrousel d’images, il appelle onColorChange sur la scène et remplace le matériau en direct. La rotation est branchée sur pointermove. C’est le niveau le plus ambitieux des trois.
AirPods Pro : le scrub vidéo, en streaming adaptatif
C’est le bundle le plus lourd des trois (881 Ko) et la page la plus pédagogique. Le DOM annonce la couleur : .video-scrub-container, .sticky-image, .scroll-item-driver, .scroll-copy. Le JS contient une méthode scrubKeyframe() qui mappe la position de scroll sur un tableau sequenceKeyframes.
Le détail que la plupart des tutoriels ratent : Apple n’utilise plus de séquence d’images. Le code fait this.isSafari ? this._requestVideo() : this._requestVideoStream() et charge hls.js depuis /ac/libs/hls.js/. Autrement dit, Safari lit la vidéo nativement, les autres navigateurs passent par du HLS adaptatif, et le navigateur ne télécharge que les segments réellement traversés par le scroll. Une séquence de 300 JPEG fait tout l’inverse.
MacBook Pro : le moteur de keyframes maison
La page la moins spectaculaire, et de loin la plus instructive. On y voit le moteur d’animation interne d’Apple à nu : un mini-langage de keyframes, avec son propre parseur d’expressions supportant clamp, lerp, smoothstep, round, cos, sin, atan.
anim.createScrollGroup().addKeyframe(el, {
start: "a0t - 100vh",
end: "lerp(0.756, a0t, a0b - 100vh)"
})
// et surtout, piloté depuis le CSS :
{ start: "css(--hz-hardware-kf-start, a0)",
end: "css(--hz-hardware-kf-end, a0)" }
a0t désigne le haut de l’ancre 0, a0b son bas, les décalages sont exprimés en vh. Conséquence directe : les points de départ et de fin des animations se règlent en CSS, donc les breakpoints responsive vivent dans la feuille de style, pas dans le JavaScript.
Deux autres choses s’y lisent. Un système de ressorts (spring.setTarget("scale", ...), snapValue, un preset bounce) plutôt que des courbes d’easing figées. Et surtout une gestion explicite du cycle de vie de will-change, avec _addWillChange et _removeWillChange déclenchés sur la plage { start: "t - 150vh", end: "b + 50vh" }. C’est exactement le détail qui sépare une page fluide d’une page qui fait tourner le ventilateur.
L’ordre de construction, de A à Z
Étape 1 : la trame
Toute section animée est un conteneur haut qui contient un enfant collant. La hauteur du conteneur, c’est la durée du scroll.
<section class="scene">
<div class="scene-sticky">
<video class="scene-video" muted playsinline preload="auto"></video>
<p class="scene-copy">Le texte qui accompagne</p>
</div>
</section>
.scene { height: 400vh; }
.scene-sticky {
position: sticky;
top: 0;
height: 100vh;
overflow: clip;
}
Ensuite une seule valeur, normalisée entre 0 et 1, que toutes les autres couches liront. Rien d’autre.
function progress(section) {
const r = section.getBoundingClientRect()
const total = r.height - window.innerHeight
return Math.min(1, Math.max(0, -r.top / total))
}
Étape 2 : le scrub vidéo
Une seule balise video, jamais de play(). La seule chose qui bouge, c’est currentTime.
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = '/media/scene.m3u8' // Safari : natif
} else {
const hls = new Hls({ maxBufferLength: 8 })
hls.loadSource('/media/scene.m3u8')
hls.attachMedia(video)
}
L’encodage compte autant que le code. Un GOP court garantit que la recherche tombe sur la bonne image plutôt que sur la dernière image-clé.
ffmpeg -i source.mov -c:v libx264 -crf 20
-g 10 -keyint_min 10 -sc_threshold 0 -an out.mp4
Étape 3 : le lissage
La valeur brute du scroll ne doit jamais atterrir directement dans un style. Elle passe par un lerp ou un ressort, et seule la valeur lissée est écrite.
let target = 0, current = 0, raf = null
function onScroll() {
target = progress(section) * video.duration
if (!raf) raf = requestAnimationFrame(tick)
}
function tick() {
current += (target - current) * 0.12
video.currentTime = current
if (Math.abs(target - current) < 0.001) {
current = target
raf = null // page au repos : zéro frame
} else {
raf = requestAnimationFrame(tick)
}
}
Ressort pour ce qui doit paraître physique (une échelle, un panneau qui s’ouvre). Lerp pour ce qui doit atterrir exactement sur une valeur (le currentTime, une rampe d’opacité).
Étape 4 : les keyframes pilotées en CSS
C’est l’idée à voler à la page MacBook Pro. Les bornes de chaque animation vivent en CSS, donc le responsive se règle sans toucher au JavaScript.
.scene { --copy-start: .15; --copy-end: .40; }
@media (max-width: 734px) {
.scene { --copy-start: .05; --copy-end: .55; }
}
const css = (el, n) => parseFloat(getComputedStyle(el).getPropertyValue(n))
const sub = (p, a, b) => Math.min(1, Math.max(0, (p - a) / (b - a)))
const smoothstep = t => t * t * (3 - 2 * t)
const t = smoothstep(
sub(p, css(scene, '--copy-start'), css(scene, '--copy-end'))
)
copy.style.opacity = t
copy.style.transform = `translate3d(0, ${(1 - t) * 24}px, 0)`
Quatre helpers purs suffisent : clamp, lerp, smoothstep, sub. Aucune formule sur mesure par section.
Étape 5 : la 3D temps réel, si le budget suit
C’est le niveau iPhone 17 Pro. Un modèle .glb, un changement de matériau pour les coloris, une rotation branchée sur la même valeur de progression, et un léger parallaxe au pointeur. Le bundle 3D se charge en dynamique, seulement quand la section approche du viewport. En dessous de ce budget, une vidéo scrubbée donne 80 % du résultat pour 10 % du coût.
Étape 6 : la performance, là où tout se joue
Reproduire le cycle de vie du will-change d’Apple, avec les mêmes marges :
const io = new IntersectionObserver(
([e]) => el.classList.toggle('will-change', e.isIntersecting),
{ rootMargin: '150% 0px 50% 0px' } // t - 150vh / b + 50vh
)
io.observe(section)
Le reste tient en trois règles : n’animer que transform et opacity, une seule boucle requestAnimationFrame pour toute la page, jamais d’écriture DOM dans un écouteur de scroll. Si la couche animation dépasse environ 300 Ko de JavaScript, il faut retirer une couche.
Étape 7 : l’accessibilité
Les trois pages Apple lisent prefers-reduced-motion au démarrage, le stockent dans un modèle unique et l’exposent en classe sur la page. À reproduire tel quel :
@media (prefers-reduced-motion: reduce) {
.scene { height: auto; }
.scene-sticky { position: static; height: auto; }
.scene-video { display: none; }
.scene-poster { display: block; }
}
Règle non négociable : le texte reste du vrai texte dans le DOM, jamais incrusté dans la vidéo. Sinon la page ne raconte plus rien une fois le mouvement désactivé, et le référencement disparaît avec.
La checklist avant de livrer
- Une seule boucle
requestAnimationFramepour tout le site, qui s’arrête quand rien ne bouge. - Vidéo en HLS, GOP court, jamais de séquence d’images.
- Bornes d’animation en variables CSS, pas en constantes JavaScript.
will-changeajouté et retiré dynamiquement, jamais posé en dur.- Uniquement
transformetopacityanimés. - Poster inline sur le hero pour ne pas faire attendre le LCP.
- Version
prefers-reduced-motionqui raconte encore toute l’histoire. - Test réel sur un mobile milieu de gamme, pas sur le simulateur du MacBook.
Pour la couche scroll générique (inertie, révélations, épinglage) qui sert de socle à tout ça, voir la ressource Réussir des animations au scroll de très haute qualité.