Animation Web Performante
Élimine la cause n°1 des animations web saccadées : l'animation de propriétés qui forcent le navigateur à recalculer la disposition (reflow) ou à repeindre chaque image. Le navigateur effectue le rendu en étapes — layout → paint → composite. Animer width, height, top, left, margin, padding, box-shadow ou filter relance le layout et/ou la peinture à chaque image, bloquant le thread principal. En revanche, animer transform et opacity s'exécute entièrement sur le compositeur (souvent le GPU), en sautant le layout et le paint, ce qui rend l'animation fluide à 60/120 fps.
Quand l'utiliser
À utiliser lorsqu'une animation saccade ou perd des images, qu'un effet de survol/défilement semble lourd, que vous animez une taille/position/ombre, qu'un changement de disposition nécessite une animation fluide (réorganisation de cartes, déplacement d'un élément entre conteneurs), que vous animez height: auto, ou que vous examinez du code d'animation pour en vérifier les performances.
Règle de base : n'animer que transform et opacity
Faites correspondre chaque animation « coûteuse » à un équivalent économique.
| Animation (coûteuse) | Déclenche | Remplacer par (économique) |
|---|---|---|
width / height | Layout + Paint | transform: scaleX()/scaleY() (+ FLIP pour la vraie taille) |
top / left / margin | Layout + Paint | transform: translate() |
box-shadow | Paint | Animer opacity d'un pseudo-élément portant l'ombre |
filter: blur() | Paint (lourd) | Transition entre deux couches via opacity, ou accepter avec modération |
background-position | Paint | transform: translate() sur une couche enfant |
color / background | Paint | Souvent acceptable ; ou transition entre couches |
Position et taille via transform
/* MAUVAIS : anime le layout à chaque image */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }
/* BON : compositeur uniquement */
.box {
transition: transform 300ms ease;
transform: translateX(0) scaleX(1);
transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }
scaleX déforme le contenu interne (le texte s'étire). Pour un redimensionnement sans déformation, utilisez FLIP.
Ombre portée économique via l'opacité d'un pseudo-élément
Animer box-shadow repeint une grande zone floutée à chaque image. À la place, peignez l'ombre une fois sur un ::after, puis animez seulement son opacity.
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }
L'ombre floutée est pixellisée une fois ; le survol ne modifie qu'une opacity sur le compositeur — fluide à n'importe quelle fréquence.
FLIP : animer les changements de disposition à moindre coût
FLIP (First, Last, Invert, Play) anime un changement de disposition (réorganisation, redimensionnement, déplacement entre conteneurs) en utilisant uniquement transform. Mesurez où se trouve l'élément (First) et où il sera (Last), appliquez une transformation inverse pour qu'il reste visuellement immobile, puis animez la transform vers l'identité (Play). Le DOM se retrouve dans sa disposition finale réelle ; le mouvement est un pur travail du compositeur.
function flip(el, mutate) {
const first = el.getBoundingClientRect(); // First
mutate(); // modifier le DOM/disposition
const last = el.getBoundingClientRect(); // Last
const dx = first.left - last.left;
const dy = first.top - last.top;
const sx = first.width / last.width;
const sy = first.height / last.height;
el.animate(
[
{ transformOrigin: 'top left',
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
{ transformOrigin: 'top left', transform: 'none' }, // Play
],
{ duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
// utilisation : animer un élément se déplaçant vers une nouvelle position de grille
flip(card, () => targetContainer.appendChild(card));
L'API Web Animations animate() exécute la transformation sur le compositeur. Pour plusieurs éléments, regroupez tous les appels getBoundingClientRect() en lecture avant toute mutation (voir le layout thrashing ci-dessous).
Animer height: auto
height: auto n'est historiquement pas interpolable. Approches modernes et de repli :
/* Moderne (Chrome 129+/navigateurs compatibles) : activer les mots-clés de taille dans l'animation */
.panel {
interpolate-size: allow-keywords; /* permet d'animer de/vers auto */
height: 0;
overflow: clip;
transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size() fonctionne aussi : height: calc-size(auto, size); */
/* Repli robuste aujourd'hui : grille CSS 1fr -> 0fr */
.wrapper {
display: grid;
grid-template-rows: 0fr; /* repliée */
transition: grid-template-rows 300ms ease;
}
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > .content { overflow: hidden; min-height: 0; }
L'astuce de la grille anime la taille de la piste (suffisamment compatible avec le compositeur) et ne nécessite aucune mesure JS. Utilisez interpolate-size: allow-keywords / calc-size(auto, size) là où les navigateurs ciblés le supportent ; gardez la technique de grille comme défaut multi-navigateurs.
Éviter le layout thrashing (regrouper les lectures, puis les écritures)
Lire une propriété de disposition (offsetWidth, getBoundingClientRect, scrollTop, getComputedStyle) après une écriture force un reflow synchrone. Alterner lectures et écritures dans une boucle (« layout thrashing ») peut déclencher des dizaines de reflows forcés par image.
// MAUVAIS : lecture, écriture, lecture, écriture... force le reflow à chaque itération
items.forEach((el) => {
const w = el.offsetWidth; // lecture (force la disposition)
el.style.width = w * 1.5 + 'px'; // écriture (invalide la disposition)
});
// BON : regrouper toutes les lectures, puis toutes les écritures
const widths = items.map((el) => el.offsetWidth); // toutes les lectures
items.forEach((el, i) => { // toutes les écritures
el.style.width = widths[i] * 1.5 + 'px';
});
Pour un travail synchronisé avec les images, lisez dans un callback requestAnimationFrame et appliquez les écritures ; des bibliothèques comme fastdom formalisent cette planification lecture/écriture.
will-change : à utiliser avec parcimonie
will-change: transform promeut un élément dans sa propre couche du compositeur à l'avance, évitant un à-coup au début de l'animation. Mais chaque couche promue coûte de la mémoire GPU, et une utilisation excessive dégrade les performances.
.menu { will-change: transform; } /* seulement sur les éléments sur le point d'animer */
Règles : appliquez juste avant l'animation (par exemple au survol/état parent), retirez-la après (will-change: auto) lorsqu'elle est inactive, ne l'appliquez jamais de façon globale à de nombreux éléments, et ne la laissez jamais en permanence sur des nœuds volumineux/nombreux. Un simple transform: translateZ(0) fait la même promotion mais est plus difficile à annuler — préférez will-change.
Livrer et vérifier (HTML autonome)
Aide empaquetée (
scripts/) :scripts/seek-shot.sh anim.html 0 1.5 3fige le harnais?t=Net capture chaque instant ;scripts/contact-sheet.sh sheet.png frame-*.pngles assemble en une seule vue pour une vérification rapide. Voirscripts/README.md.
Le livrable est un seul .html autonome qui s'ouvre directement dans un navigateur — le balisage, l'animation CSS/JS et un harnais de gel dans un seul fichier. Pour cette compétence, la vérification est double : l'image doit être correcte et peu coûteuse à produire (compositeur uniquement).
Contrat de livraison :
- Un
.html, dépendances via CDN si nécessaire, animation via transitions CSS/@keyframesou Web Animations API. - Un mécanisme de gel pour qu'une capture d'écran tombe sur une image déterministe :
- CSS
@keyframes→?t=Ndéfinitel.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused'. - WAAPI / JS → gardez l'objet animation et
anim.pause(); anim.currentTime = N*1000.
- CSS
Boucle de vérification — geler → capturer, puis profiler pour les saccades :
- Ouvrez en mode headless au début / milieu / fin (
?t=0, milieu, fin), capturez chaque image ; confirmez que le mouvement est visuellement correct (pas de texte coupé/étiré à cause descaleX, FLIP atterrit sur la disposition réelle, les transitions d'ombre/hauteur semblent correctes). - Confirmez qu'il s'agit uniquement du compositeur — tout l'intérêt de cette compétence. Soit :
- DevTools → Performance : enregistrez l'animation, vérifiez qu'il n'y a aucune bande violette « Layout » ou verte « Paint » par image (seulement « Composite Layers »).
- Ou trace headless :
npx playwright screenshotpour le visuel, plus une capture CDP/tracing, et affirmez qu'aucun événementLayout/Paintne se déclenche pendant la fenêtre d'animation.
- Vérifiez que le compteur FPS / « Frame Rendering Stats » reste à 60 — des images perdues signifient qu'une propriété coûteuse s'est glissée.
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png
Avant de terminer :
- S'ouvre de manière autonome — aucune erreur dans la console.
- Seules
transform/opacitysont animées par image ; DevTools n'affiche aucun Layout/Paint par image. - Capturé au début / milieu / fin — correct, pas de distorsion de texte due à
scaleX, FLIP atterrit juste. - Maintient 60fps ;
will-changeappliqué juste à temps et retiré lorsqu'inactif (pas de couches résiduelles). prefers-reduced-motionrespecté.
Référence rapide
| Objectif | Action |
|---|---|
| Déplacer un élément | transform: translate() |
| Redimensionner sans déformation | FLIP avec transform |
| Ombre au survol | Animer opacity du pseudo-élément d'ombre |
| Déplier à la hauteur du contenu | grille 0fr→1fr, ou interpolate-size: allow-keywords |
| Plusieurs éléments en mouvement | Regrouper les lectures getBoundingClientRect, puis les écritures |
| Début d'animation fluide | will-change juste à temps, retirer lorsqu'inactif |
| Vérifier que c'est compositeur uniquement | DevTools Performance : pas de Layout violet / Paint vert par image |
Pièges
scaleX/scaleYétire le texte et les enfants ; utilisez FLIP quand le contenu doit rester net.- Les pourcentages de
transformsont relatifs à la boîte de l'élément lui-même, pas au parent — différent deleft: %. - Abuser de
will-changeoutranslateZ(0)crée trop de couches et nuit aux performances ; ne promouvez que ce qui s'anime. filteretbackdrop-filtersont liés au compositeur mais restent coûteux ; animez leur présence via des transitions d'opacité plutôt que le rayon de flou.- Le contenu de la grille
1fr→0frdoit avoiroverflow: hiddenetmin-height: 0sinon il ne se repliera pas. - Toujours conditionner les animations non essentielles derrière
@media (prefers-reduced-motion: reduce).
Fichiers de référence
references/patterns-and-profiling.md— exemples complets exécutables (accordéon, liste réorganisable, parallaxe), procédure de profilage DevTools pour confirmer des images uniquement compositeur, motifs de mouvement réduit, et une antisèche sur le coût des propriétés.