CLS : identifier et éliminer les décalages de layout

Un site e-commerce de 12 000 fiches produit passe de 0.04 à 0.38 de CLS après un déploiement anodin — une bannière promotionnelle injectée en JavaScript au-dessus du fold. Le trafic organique chute de 14 % en trois semaines. Le problème ne remonte dans la Search Console qu'avec un délai de 28 jours. Trois semaines de données field accumulées, trois semaines de revenus perdus.

Le Cumulative Layout Shift est le Core Web Vital le plus traître. Contrairement au LCP ou à l'INP, il ne dépend pas de la puissance du serveur ou de la rapidité du réseau. Il dépend de la stabilité visuelle du DOM pendant le chargement et l'interaction. Et les régressions sont silencieuses — elles ne cassent rien fonctionnellement, elles dégradent juste l'expérience utilisateur de façon insidieuse.

Comment le CLS est réellement calculé

La métrique CLS ne mesure pas un seul décalage. Elle agrège les décalages de layout dans des fenêtres de session (session windows) de maximum 5 secondes, avec un gap maximal de 1 seconde entre deux shifts consécutifs. Le score CLS final est la valeur maximale parmi toutes ces fenêtres de session — pas la somme totale de tous les décalages sur la durée de vie de la page.

Cette nuance est capitale. Un décalage unique et massif (0.25) est pire qu'une série de micro-décalages répartis sur 30 secondes (dix shifts de 0.03 dans des fenêtres distinctes). Le seuil "bon" fixé par Google est 0.1. Au-delà de 0.25, le score est considéré "mauvais".

Le calcul d'un layout shift individuel

Chaque layout shift individuel se calcule ainsi :

layout shift score = impact fraction × distance fraction

L'impact fraction représente la proportion du viewport affectée par les éléments qui bougent (leur position avant + leur position après). La distance fraction mesure la distance maximale parcourue par un élément instable, divisée par la plus grande dimension du viewport.

Un élément de 200px de haut qui glisse de 150px vers le bas dans un viewport de 800px :

  • Impact fraction : (200 + 150) / 800 = 0.4375
  • Distance fraction : 150 / 800 = 0.1875
  • Score du shift : 0.4375 × 0.1875 = 0.082

Ce seul shift consomme 82 % du budget CLS autorisé.

Les shifts exclus du calcul

Tous les déplacements ne comptent pas. Les shifts qui surviennent dans les 500ms suivant une interaction utilisateur (clic, tap, keypress) sont marqués hadRecentInput: true et exclus du CLS. C'est ce qui permet aux accordéons, menus déroulants et onglets de fonctionner sans pénalité.

En revanche, les animations CSS via transform ne génèrent aucun layout shift car elles ne modifient pas la géométrie du layout. C'est pourquoi animer avec transform: translateY() est safe, mais animer top, margin-top ou height ne l'est pas.

Les six causes techniques les plus fréquentes

Images et vidéos sans dimensions explicites

La cause la plus documentée reste la plus courante en pratique. Une balise <img> sans attributs width et height laisse le navigateur incapable de réserver l'espace avant le chargement de la ressource. Le navigateur alloue 0px de hauteur, charge l'image, puis repousse tout le contenu en dessous.

<!-- ❌ Génère un layout shift -->
<img src="/products/chaussure-running-42.webp" alt="Chaussure running taille 42">

<!-- ✅ Espace réservé via width/height natifs -->
<img
  src="/products/chaussure-running-42.webp"
  alt="Chaussure running taille 42"
  width="800"
  height="600"
  loading="lazy"
  decoding="async"
>

<!-- ✅ Alternative CSS avec aspect-ratio -->
<img
  src="/products/chaussure-running-42.webp"
  alt="Chaussure running taille 42"
  style="aspect-ratio: 4/3; width: 100%; height: auto;"
  loading="lazy"
  decoding="async"
>

La propriété CSS aspect-ratio est supportée par tous les navigateurs modernes (baseline depuis 2021 selon MDN). Elle permet de gérer proprement les images responsives dont les dimensions en attributs HTML seraient écrasées par le CSS.

Sur un site de 12 000 fiches produit avec 4 images par fiche, corriger ce seul point peut réduire le CLS de 0.15 à 0.02 sur les pages produit.

Injection de contenu dynamique au-dessus du fold

Bannières cookies, notifications promotionnelles, barres d'information — tout élément injecté en JavaScript au-dessus du contenu visible au chargement provoque un décalage. Le scénario type : un script tiers charge une bannière RGPD qui s'insère en haut du <body> et pousse l'intégralité de la page vers le bas.

La solution technique dépend du contexte :

Réserver l'espace statiquement si la hauteur est prévisible :

/* Réservation d'espace pour la bannière cookie */
.cookie-banner-placeholder {
  min-height: 80px; /* hauteur connue de la bannière */
  contain: layout;
}

/* Si la bannière n'est pas nécessaire (cookie déjà accepté) */
.cookie-banner-placeholder--hidden {
  min-height: 0;
}

Utiliser un overlay au lieu d'un insert : les éléments en position: fixed ou position: absolute ne participent pas au flow du document et ne génèrent donc aucun layout shift. Une bannière cookie en overlay plein écran (comme celles de Didomi ou Axeptio) a un CLS de 0 — contrairement à un bandeau inséré dans le flow.

Utiliser content-visibility: auto sur les sections below-the-fold pour limiter la portée des recalculs de layout lorsqu'un élément est injecté.

Fonts web et FOUT/FOIT

Le chargement d'une font web provoque un layout shift quand la font de fallback et la font finale ont des métriques différentes (x-height, largeur des glyphes, line-height effectif). Le texte "saute" visuellement au moment du swap.

La solution la plus efficace combine plusieurs techniques :

/* 1. Ajuster les métriques de la font de fallback */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  /* Pas de size-adjust ici — on le fait sur la fallback */
}

@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter-fallback', sans-serif;
}

Les propriétés size-adjust, ascent-override, descent-override et line-gap-override permettent d'aligner les métriques de la fallback sur celles de la font cible. L'outil Fontaine ou la fonctionnalité optimizeFonts de Next.js automatisent ce calcul.

En complément, préchargez les fonts critiques :

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

Le crossorigin est obligatoire même en same-origin pour les fonts — sans lui, le navigateur effectue un double fetch.

Ads et embeds tiers sans réservation d'espace

Les publicités (Google AdSense, emplacements programmatiques) et les embeds (YouTube, Twitter/X, Instagram) sont des sources de CLS massif. L'ad server retourne des créatives de dimensions variables, souvent avec un délai de plusieurs secondes.

La stratégie défensive : réserver systématiquement l'espace du plus grand format possible pour chaque emplacement.

<!-- Réservation pour un emplacement MPU 300x250 -->
<div class="ad-slot ad-slot--mpu" style="min-height: 250px; min-width: 300px;">
  <div id="ad-mpu-sidebar"></div>
</div>

<!-- Réservation pour un embed YouTube 16:9 -->
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
  <iframe
    src="https://www.youtube.com/embed/dQw4w9WgXcQ"
    style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
    loading="lazy"
    title="Titre de la vidéo"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
    allowfullscreen>
  </iframe>
</div>

Pour un site média avec 8 emplacements pub par page article, cette seule correction réduit typiquement le CLS de 0.3+ à moins de 0.05.

Contenu injecté par hydration côté client

Sur les applications SSR (Next.js, Nuxt, SvelteKit), un décalage fréquent survient quand le HTML rendu côté serveur ne correspond pas au résultat de l'hydration côté client. Le client "corrige" le DOM, ce qui provoque un layout shift.

Ce problème est détaillé dans notre article sur les hydration mismatch. Les causes classiques :

  • Affichage conditionnel basé sur window.innerWidth (absent côté serveur)
  • Contenu personnalisé (panier, nom utilisateur) rendu différemment server-side vs client-side
  • Date.now() ou timestamps qui diffèrent entre SSR et hydration

La solution : isoler le contenu dynamique dans des composants qui ne se rendent qu'après hydration, en utilisant des placeholders de dimensions fixes côté serveur.

// Composant React qui évite le layout shift pendant l'hydration
import { useState, useEffect } from 'react';

function ClientOnlyBanner({ height = 60 }: { height?: number }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    // Placeholder avec exactement la même hauteur que le contenu final
    return <div style={{ minHeight: height }} aria-hidden="true" />;
  }

  return (
    <div style={{ minHeight: height }}>
      <PromotionalBanner />
    </div>
  );
}

Le choix du mode de rendering (SSR, ISR ou SSG) a un impact direct sur la fréquence de ces mismatches. Les pages statiques (SSG) sont moins sujettes aux décalages d'hydration puisque le HTML est figé au build time.

CSS chargé de façon asynchrone ou tardive

Un anti-pattern courant dans les optimisations de performance : extraire le CSS non-critique et le charger en async. Si ce CSS affecte des éléments au-dessus du fold, son application tardive provoque un recalcul de layout.

<!-- ❌ Le CSS critique pour le layout ne doit PAS être chargé en async -->
<link rel="preload" href="/css/above-fold.css" as="style" onload="this.onload=null;this.rel='stylesheet'">

<!-- ✅ Inline le CSS critique directement dans le <head> -->
<style>
  .hero { min-height: 500px; }
  .nav { height: 64px; }
  .product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
  .product-card img { aspect-ratio: 1; width: 100%; height: auto; }
</style>

<!-- Charger le reste en async -->
<link rel="stylesheet" href="/css/main.css" media="print" onload="this.media='all'">

La règle : tout CSS qui définit des dimensions, des grilles, ou des propriétés de layout (display, position, flex, grid, width, height, margin, padding) pour les éléments above-the-fold doit être inline ou chargé de façon bloquante.

Diagnostic : les outils et la méthode

Chrome DevTools — le diagnostic en lab

L'onglet Performance de DevTools reste le meilleur outil pour identifier les layout shifts individuels. La méthode :

  1. Ouvrir DevTools → Performance → cocher "Web Vitals"
  2. Cliquer "Record" puis recharger la page
  3. Les layout shifts apparaissent sous forme de barres rouges dans le lane "Experience"
  4. Cliquer sur chaque shift révèle les éléments concernés dans le panneau "Summary"

Pour un diagnostic plus granulaire, le snippet JavaScript suivant capture chaque shift avec les éléments responsables :

// Observer les layout shifts en temps réel
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.hadRecentInput) {
      console.group(`Layout Shift: ${entry.value.toFixed(4)}`);
      console.log('Start time:', entry.startTime.toFixed(0), 'ms');
      for (const source of entry.sources || []) {
        console.log(
          'Element:', source.node?.nodeName,
          'Classes:', source.node?.className,
          'Previous rect:', JSON.stringify(source.previousRect),
          'Current rect:', JSON.stringify(source.currentRect)
        );
      }
      console.groupEnd();
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Ce script est plus utile que les outils intégrés car il expose entry.sources — la liste des éléments DOM qui ont physiquement bougé. C'est l'information clé pour passer du "il y a un shift" au "cet élément est responsable".

Search Console — les données field à grande échelle

Le rapport Core Web Vitals de la Search Console agrège les données CrUX (Chrome User Experience Report) sur 28 jours glissants. C'est la source que Google utilise pour le ranking signal. L'onglet permet de regrouper les URL par pattern pour identifier les templates problématiques.

Limitation majeure : le délai. Un déploiement qui casse le CLS le lundi ne sera visible dans la Search Console que 28 jours plus tard, quand les données field auront suffisamment accumulé. C'est précisément le type de régression silencieuse qu'un outil de monitoring comme SEOGard détecte en continu, avant que l'impact ne se retrouve dans les données CrUX.

Screaming Frog + Lighthouse en bulk

Pour auditer le CLS de templates entiers, Screaming Frog permet de lancer des audits Lighthouse sur chaque URL crawlée. Configuration :

  1. Configuration → Spider → Rendering → JavaScript
  2. Configuration → API Access → PageSpeed Insights (entrer votre clé API)
  3. Lancer le crawl sur un échantillon représentatif de chaque template

Les résultats incluent le CLS lab pour chaque page. Exportez en CSV, pivotez par template URL (regex sur le path), et identifiez les patterns avec un CLS médian supérieur à 0.1.

Attention : le CLS lab (Lighthouse, DevTools) et le CLS field (CrUX, Search Console) diffèrent souvent significativement. Le lab ne capture que le chargement initial. Le field inclut les shifts pendant toute la session utilisateur — scroll, lazy loading, interactions. Un CLS lab de 0 ne garantit pas un CLS field de 0.

Scénario concret : migration et régression CLS d'un média en ligne

Un média en ligne de 25 000 articles migre de WordPress monolithique vers un frontend Next.js en SSR. Avant migration, le CLS field médian est de 0.06 — dans le vert.

Deux semaines après la mise en production, le rapport CrUX montre un CLS médian de 0.28 sur les pages articles (18 000 URL). Le trafic organique sur ces pages baisse de 11 % sur le mois suivant.

Diagnostic

L'équipe lance le snippet d'observation des layout shifts sur une page article type. Trois sources de shifts identifiées :

  1. Images d'article (CLS +0.12) : le composant <Image> de Next.js était configuré avec fill sans wrapper de dimensions fixes. Les images éditoriales ont des ratios variables (16:9, 4:3, 1:1) et le CMS ne transmet pas les dimensions.

  2. Bannière pub header (CLS +0.09) : l'emplacement Prebid.js en haut de page s'insère 800ms après le DOMContentLoaded sans espace réservé.

  3. Font swap (CLS +0.06) : la font éditoriale "Merriweather" en font-display: swap depuis Google Fonts a des métriques très différentes de la fallback "Georgia".

Corrections appliquées

Images : extraction des dimensions depuis le CMS (stockées en base), passage en width/height explicites. Pour les images sans dimensions en base, un script de migration interroge les fichiers avec sharp pour extraire les métadonnées et backfill la base de données.

Pub : réservation d'espace fixe de 90px pour le leaderboard header, avec min-height en CSS inline dans le template SSR. L'emplacement n'apparaît jamais plus grand que 90px — si la créative est plus petite, l'espace restant est comblé par un fond neutre.

Fonts : remplacement de Google Fonts par un self-hosting avec descripteurs @font-face ajustés via Fontaine. Le size-adjust de 98% et les overrides d'ascent/descent réduisent le shift de swap à un niveau imperceptible.

Résultats

Après 28 jours de données field post-correction : CLS médian de 0.04 sur les pages articles. Le trafic organique retrouve son niveau pré-migration en 6 semaines (le délai inclut le temps de réévaluation par Google des Core Web Vitals dans ses signaux de ranking, comme documenté dans le rapport sur l'impact réel des Core Web Vitals).

Techniques avancées de prévention

La propriété CSS contain

La propriété contain indique au navigateur qu'un élément est indépendant du reste du document pour certains aspects du rendu. Appliquée stratégiquement, elle limite la portée des recalculs de layout :

/* L'élément ne peut pas affecter le layout des éléments extérieurs */
.sidebar-widget {
  contain: layout;
}

/* Stricte : layout + paint + size — le navigateur peut skip entièrement
   le rendu si l'élément est hors viewport */
.article-comment {
  contain: strict;
  /* Attention : contain: strict inclut contain: size,
     ce qui impose de déclarer width/height explicitement */
  height: 120px;
}

/* content-visibility: auto = contain strict + skip rendering hors viewport */
.below-fold-section {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* estimation de la hauteur */
}

content-visibility: auto est particulièrement efficace sur les pages longues (articles de blog, pages catégories e-commerce). Le navigateur ne calcule le layout des sections off-screen qu'au moment où elles entrent dans le viewport. Cela réduit le temps de rendu initial et confine les shifts potentiels.

Documentation de référence : content-visibility sur web.dev.

Animations sans layout shift

Toute animation qui modifie des propriétés de layout (width, height, top, left, margin, padding) provoque des layout shifts pendant l'animation (sauf si déclenchée par une interaction utilisateur récente).

La règle stricte : n'animer que transform et opacity. Ces propriétés sont composées par le GPU sans recalcul de layout.

/* ❌ Anime des propriétés de layout */
.notification-enter {
  animation: slideDown 300ms ease-out;
}
@keyframes slideDown {
  from { height: 0; margin-top: 0; }
  to { height: 60px; margin-top: 16px; }
}

/* ✅ Même effet visuel, aucun layout shift */
.notification-enter {
  height: 60px; /* espace réservé dès le départ */
  margin-top: 16px;
  animation: slideDownTransform 300ms ease-out;
}
@keyframes slideDownTransform {
  from { transform: translateY(-76px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}

Monitoring automatisé du CLS en CI/CD

Intégrer un check de CLS dans votre pipeline de déploiement empêche les régressions d'atteindre la production. Lighthouse CI est l'outil standard :

# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci && npm run build
      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/[email protected]
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: [
        'http://localhost:3000/',
        'http://localhost:3000/produits/chaussure-running-42',
        'http://localhost:3000/blog/article-test',
      ],
      numberOfRuns: 3,
      startServerCommand: 'npm run start',
    },
    assert: {
      assertions: {
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

Ce setup bloque tout merge qui fait passer le CLS au-dessus de 0.1 sur les pages testées. C'est une protection lab — elle ne couvre pas les shifts field (lazy-loaded content, ads, etc.) mais attrape les régressions les plus grossières.

Pour les données field, la Web Vitals JavaScript library de Google permet de remonter le CLS réel de vos utilisateurs vers votre analytics. Combiné avec un outil de monitoring continu comme SEOGard qui surveille les changements de meta et de rendering, vous couvrez à la fois la couche SEO technique et la couche performance.

Les pièges du CLS qu'on ne trouve pas dans la doc officielle

Le CLS mobile vs desktop diverge fortement. Un layout stable sur desktop peut être catastrophique en mobile à cause des viewports étroits. Un shift de 50px représente un distance fraction de 0.06 sur un écran 800px mais 0.075 sur un écran 667px. Testez toujours les deux.

Les Single Page Applications accumulent du CLS. Sur une SPA, les navigations client-side ne réinitialisent pas le CLS. Un utilisateur qui navigue sur 15 pages sans hard reload accumule les shifts de chaque "page". La métrique CLS utilise les session windows pour atténuer ce problème, mais une SPA avec des transitions mal gérées peut avoir un CLS field bien supérieur au CLS lab mesuré sur un page load unique.

loading="lazy" peut causer du CLS. Si une image lazy-loaded n'a pas de dimensions réservées, son apparition dans le viewport provoque un shift. Pire : si le threshold de chargement est trop conservateur, l'image peut apparaître visiblement dans le viewport (l'utilisateur voit l'espace se remplir). Assurez-vous que width/height ou aspect-ratio sont toujours présents sur les images lazy.

Les animations <details>/<summary> natifs causent du CLS. L'ouverture d'un élément <details> repousse le contenu en dessous. Ce shift est exclu du CLS uniquement si l'ouverture est déclenchée par un clic (grâce à la règle hadRecentInput). Mais si vous ouvrez un <details> programmatiquement via JavaScript sans interaction préalable, le shift compte.

Le CLS est la métrique des Core Web Vitals où les micro-détails d'implémentation ont le plus d'impact. La correction est rarement complexe — réserver de l'espace, utiliser transform plutôt que des propriétés de layout, inliner le CSS critique. La difficulté réside dans la détection : les régressions sont visuellement subtiles, absentes des logs d'erreur, et ne remontent qu'avec un délai dans les outils standard. Un monitoring continu de vos pages critiques — que ce soit via Lighthouse CI en pré-production ou un outil comme SEOGard en production — transforme le CLS d'un problème subi en une métrique maîtrisée.

Articles connexes

Performance3 mars 2026

Font loading et SEO : FOUT, FOIT et optimisations CLS

Stratégies avancées de chargement de polices web pour éliminer FOUT/FOIT, réduire le CLS et préserver la performance SEO. Code, config et cas concrets.

Performance2 mars 2026

Lazy loading : bonnes pratiques et pièges SEO à éviter

Implémentez le lazy loading sans casser l'indexation Google. Code, config et scénarios concrets pour sites de grande envergure.

Performance1 mars 2026

Images SEO : WebP, AVIF, lazy loading et responsive en production

Guide technique complet pour optimiser vos images : formats modernes, lazy loading natif, responsive images et impact réel sur les Core Web Vitals.