← back to index
Lighthouse22 mai 2026 · 8 min read

Comment corriger le Cumulative Layout Shift (CLS) avec Next.js

Le CLS est la Core Web Vital où Lighthouse te ment régulièrement. Voici comment trouver les vraies causes d'instabilité visuelle et les corriger en Next.js : images, polices, contenu dynamique et animations.

Cet article est la partie 5 et le dernier de la série Lighthouse Performance. La partie 4 couvrait le TBT et se terminait sur une promesse à propos de cette métrique. C'est l'heure de la tenir.

Le CLS mesure la stabilité visuelle: dans quelle mesure ta page saute pendant son chargement. Tu cliques sur un bouton et une bannière apparaît juste au-dessus avant que ton tap ne se termine. Tu lis un paragraphe et il descend d'un coup. C'est le CLS qui fait des dégâts.

Ce qui le rend tordu: ton score Lighthouse et ton score terrain divergent souvent de façon significative. Lighthouse fait un seul chargement de page dans un environnement contrôlé. Le CLS en field se mesure sur toute la durée de vie de la page: chaque scroll, chaque image chargée en lazy, chaque swap de police. Un score lab à 0.05 avec un score terrain à 0.35, c'est courant, et ça signifie que la majorité des sauts se produisent après le chargement initial.

Pour offrir une bonne expérience utilisateur, les pages devraient viser un CLS de 0.1 ou moins pour au moins 75% des visites. (web.dev)

Ce que le CLS mesure vraiment

Le score n'est pas une durée. C'est un nombre sans unité calculé à partir de deux fractions par saut:

score du saut = impact fraction × distance fraction
 
Exemple:
  Un élément occupe 75% du viewport (avant + après combinés)
  L'élément descend de 25% de la hauteur du viewport
 
  score = 0.75 × 0.25 = 0.1875  → "Amélioration nécessaire"

Le score CLS final est la somme des scores de sauts dans la pire fenêtre de session: une fenêtre de 5 secondes qui maximise le total. Les sauts causés par une interaction utilisateur dans les 500ms qui suivent sont exclus.

Trouver tes sauts avant de les corriger

Ne devine pas. Le panneau Performance des DevTools est le bon point de départ pour le CLS au chargement. Ouvre-le, enregistre un chargement de page, puis regarde la piste Layout Shifts: des barres violettes regroupées en clusters, avec des losanges pour les sauts individuels. Clique sur un losange pour voir une animation du saut et les éléments concernés.

Pour le CLS post-chargement (celui qui apparaît dans les données terrain mais pas dans Lighthouse), la vue Live Metrics du panneau Performance est plus utile. Elle te laisse interagir avec la page pendant que le score CLS se met à jour en temps réel.

Tu peux aussi observer les sauts par code et les envoyer à ton analytics:

// À coller dans la console ou à intégrer à ton tracking de vitals
new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    // hadRecentInput filtre les sauts initiés par l'utilisateur
    if (!entry.hadRecentInput) {
      console.log(
        `Layout shift: score=${entry.value.toFixed(4)}`,
        entry.sources,
      );
    }
  });
}).observe({ type: "layout-shift", buffered: true });

Le tableau sources sur chaque entrée pointe vers les éléments qui ont bougé. C'est ça qui compte: pas juste "il y a eu un saut" mais "cet élément précis en est la cause."

Correction 1 : Images et médias sans dimensions

C'est la cause la plus fréquente, et elle se corrige simplement. Quand le navigateur ne connaît pas les dimensions d'une image avant qu'elle se charge, il lui réserve zéro espace. L'image arrive, prend de la place, et tout ce qui est en dessous descend.

La correction: toujours déclarer width et height sur les éléments <img>. Les navigateurs modernes utilisent ces attributs pour calculer un aspect-ratio avant le chargement, donc le bon espace est réservé immédiatement. (web.dev)

<!-- ❌ Pas de dimensions: le navigateur réserve 0px, le contenu saute au chargement -->
<img src="/hero.jpg" alt="Image hero" />
 
<!-- ✅ Dimensions déclarées: le navigateur réserve le bon espace immédiatement -->
<img src="/hero.jpg" width="1200" height="630" alt="Image hero" />

Le CSS correspondant pour que ça fonctionne en responsive:

img {
  width: 100%;
  height: auto; /* préserve le aspect-ratio calculé depuis les attributs width/height */
}

Avec Next.js: utilise next/image

next/image gère tout ça automatiquement. Il requiert les props width et height, génère le srcset pour les images responsive, et applique le lazy loading par défaut. Pour les images au-dessus de la fold, ajoute priority pour désactiver le lazy loading et précharger l'image:

// src/components/HeroImage.tsx
import Image from "next/image";
 
// ✅ Dimensions requises: next/image applique aspect-ratio et srcset automatiquement
export function HeroImage() {
  return (
    <Image
      src="/hero.jpg"
      alt="Image hero"
      width={1200}
      height={630}
      priority // précharge l'image, utile pour les éléments LCP
    />
  );
}

Pour les images dont les dimensions sont inconnues au build (contenu utilisateur, images CMS), utilise fill avec un conteneur positionné:

// Le conteneur contrôle l'espace, Image le remplit — pas de layout shift
<div className="relative aspect-video w-full">
  <Image src={src} alt={alt} fill className="object-cover" />
</div>

Correction 2 : Contenu dynamique et placeholders skeleton

Le contenu injecté après le chargement est la deuxième plus grande source de CLS. Le pattern est toujours le même: quelque chose se charge, prend de la place, et pousse le contenu existant vers le bas. Pubs, bannières, notices de cookies, sections "articles recommandés" tirées d'une API — elles font toutes ça si tu les laisses faire.

La règle: si tu sais que quelque chose va apparaître, réserve son espace avant qu'il arrive.

Réserver l'espace pour du contenu à taille connue

Utilise min-height ou aspect-ratio pour maintenir le slot ouvert pendant le chargement:

// src/components/AdSlot.tsx
 
// ❌ Le contenu apparaît de nulle part et décale tout ce qui est en dessous
export function AdSlot() {
  const [ad, setAd] = useState(null);
  useEffect(() => {
    fetchAd().then(setAd);
  }, []);
  return ad ? <div>{ad}</div> : null;
}
 
// ✅ L'espace est réservé que la pub se charge ou non
export function AdSlot() {
  const [ad, setAd] = useState(null);
  useEffect(() => {
    fetchAd().then(setAd);
  }, []);
  return (
    <div style={{ minHeight: "250px" }}>
      {ad ?? <div className="animate-pulse bg-muted rounded" />}
    </div>
  );
}

Ça rejoint directement ce que j'évoquais dans l'article TBT: la prop loading dans next/dynamic sert exactement à ça. Quand un composant lourd est splitté dans son propre chunk, le placeholder maintient l'espace pendant le téléchargement:

// src/components/SomethingHeavy.tsx
import dynamic from "next/dynamic";
 
const HeavyFeatureImpl = dynamic(() => import("./HeavyFeatureImpl"), {
  // Ce skeleton maintient l'espace exact qu'occupera le composant
  // Sans lui: le composant se monte et pousse le contenu → CLS
  loading: () => <div className="animate-pulse bg-muted rounded-xl h-48" />,
});

Contenu dynamique sans interaction

Évite d'injecter du nouveau contenu dans le viewport sans action utilisateur. Les bannières qui apparaissent en haut de page après le chargement sont un classique. Si tu ne peux pas réserver leur espace, positionne-les en overlay (position: fixed ou absolute) pour qu'elles ne poussent pas le reste.

Pour les patterns "charger plus": laisse l'utilisateur déclencher le chargement explicitement. Les sauts qui surviennent dans les 500ms qui suivent une interaction sont exclus du score CLS, ce qui veut dire qu'un clic suivi de nouveau contenu, c'est acceptable. Le scroll infini silencieux qui injecte du contenu au-dessus de la position de scroll actuelle, non. (web.dev)

Correction 3 : Polices web

Les polices web causent du CLS de deux façons:

(web.dev)

font-display: optional — l'option zéro saut

font-display: optional dit au navigateur d'utiliser la police web uniquement si elle est déjà disponible en cache au premier rendu. Sinon, la substitution est utilisée définitivement pour ce chargement. Pas de swap, pas de saut:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter.woff2") format("woff2");
  font-display: optional; /* pas de FOUT, pas de layout shift */
}

Le compromis: à la première visite, l'utilisateur peut voir la police de substitution. Aux visites suivantes (une fois la police en cache), il voit Inter. Pour la plupart des cas d'usage, c'est acceptable.

Le préchargement pour gagner la course

Si tu veux la police web dès la première visite sans provoquer de saut, précharge-la pour qu'elle soit disponible avant le premier paint:

// src/app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="fr">
      <head>
        <link
          rel="preload"
          href="/fonts/inter.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Avec font-display: swap et un préchargement, la police web gagne généralement la course du premier paint et aucun swap ne se produit. C'est moins garanti qu'optional, mais ça donne la police web dès la première visite.

Minimiser l'écart de métriques entre les polices

Quand un swap est inévitable, size-adjust, ascent-override et descent-override permettent d'ajuster la police de substitution pour qu'elle corresponde au mieux à la police web, de sorte que le bloc de texte change à peine de taille au moment du swap:

/* Substitution ajustée: tweaker les pourcentages jusqu'à ce que le saut soit imperceptible */
@font-face {
  font-family: "Inter-fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
}
 
body {
  font-family: "Inter", "Inter-fallback", sans-serif;
}

Trouver les bonnes valeurs demande un peu de tâtonnement dans les DevTools. Le post Improved font fallbacks de l'équipe Chrome propose une approche calculatoire qui accélère le processus.

Correction 4 : Animations qui causent des sauts

Tous les sauts ne viennent pas du chargement de contenu. Les animations CSS qui déplacent des éléments via des propriétés qui déclenchent un recalcul de layout contribuent aussi au CLS.

Les coupables: toutes les propriétés qui affectent le flux du document, top, left, right, bottom, width, height, margin, padding. Quand elles changent, le navigateur doit recalculer le layout de l'élément et potentiellement refaire le reflow de toute la page.

La correction: utiliser des propriétés composited à la place. Les animations composited tournent sur le GPU et court-circuitent entièrement le layout et le paint:

/* ❌ Déclenche un recalcul de layout à chaque frame — contribue au CLS */
.notification {
  position: relative;
  top: 0;
  transition: top 0.3s ease;
}
.notification.hidden {
  top: -100px;
}
 
/* ✅ Composited: pas de recalcul layout, pas de contribution au CLS */
.notification {
  transition: transform 0.3s ease;
}
.notification.hidden {
  transform: translateY(-100px);
}
PropriétéDéclenche layoutUtiliser à la place
top, left, right, bottomOuitransform: translate()
width, heightOuitransform: scale()
margin, paddingOuitransform: translate()
opacityNon(déjà composited)
transformNon(déjà composited)
filterNon (en général)(déjà composited)

(web.dev)

Le principe clé: si une animation déplace ou redimensionne un élément sans affecter l'espace qu'il occupe dans le flux du document, elle ne peut pas causer de layout shift.

En bonus : le bfcache

Le back/forward cache (bfcache) est une optimisation navigateur qui garde les pages en mémoire quand tu navigues vers une autre page. Quand l'utilisateur appuie sur le bouton retour, la page est restaurée instantanément depuis la mémoire plutôt que rechargée. Pas de rechargement, pas de sauts.

En janvier 2022, quand Chrome a déployé le bfcache plus largement, le Chrome UX Report a enregistré des améliorations significatives des scores CLS à l'échelle du web sans aucun changement de code de la part des propriétaires de sites. (CrUX release notes, jan. 2022)

La plupart des pages sont éligibles au bfcache par défaut. Quelques éléments peuvent disqualifier la tienne:

Tu peux tester l'éligibilité directement dans les DevTools: onglet Application → Back/forward cache → Test. Si ta page n'est pas éligible, les DevTools listent les raisons exactes.

Seuils et ce à quoi s'attendre

CLSÉvaluation
0 – 0.1Vert
0.1 – 0.25Amélioration nécessaire
Plus de 0.25Mauvais

Une note sur la priorisation: les images sans dimensions et les swaps de polices sont les corrections les plus rapides et souvent les plus impactantes. Le contenu dynamique et les animations demandent plus de travail mais valent l'effort si tes données terrain montrent du CLS post-chargement.

Valide avec Lighthouse et PageSpeed Insights. S'ils divergent de façon significative après tes corrections, il te reste du CLS post-chargement à chasser.


C'est la fin de la série. Quatre métriques, quatre articles: FCP, LCP, Speed Index, TBT, et maintenant CLS. Chacune a une cause différente, et corriger l'une sans comprendre les autres déplace souvent le problème plutôt que de le résoudre.

Si tu as appliqué ces corrections et que tu vois encore un score terrain récalcitrant, la réponse est presque toujours dans les données utilisateurs réels: quelles pages, quels appareils, quelles interactions. La librairie web-vitals avec les données d'attribution est le bon outil pour cette prochaine étape.