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

Un utilisateur clique sur "Ajouter au panier" et la page se décale de 40 pixels au même instant. Il achète le mauvais produit — ou pire, il quitte le site. Ce décalage, Google le mesure et l'intègre dans ses Core Web Vitals sous le nom de Cumulative Layout Shift (CLS). Un e-commerce de 22 000 pages produit peut perdre entre 8 et 15 % de son taux de conversion sur mobile simplement parce que des éléments injectés dynamiquement décalent le contenu visible.

Ce que mesure réellement le CLS — et ce qu'il ne mesure pas

Le CLS ne comptabilise pas tous les déplacements d'éléments. Il mesure uniquement les layout shifts inattendus — ceux qui ne surviennent pas dans les 500 ms suivant une interaction utilisateur (tap, click, keypress). Un accordéon qui s'ouvre au clic ne génère pas de CLS. Un bandeau de consentement qui s'injecte 2 secondes après le chargement initial, si.

La formule de calcul

Chaque layout shift individuel reçoit un score calculé ainsi :

layout shift score = impact fraction × distance fraction

L'impact fraction correspond à la proportion du viewport affectée par le déplacement. La distance fraction mesure la distance de déplacement du plus grand élément impacté, rapportée à la dimension du viewport (hauteur ou largeur selon l'axe).

Le CLS final rapporté dans les données de terrain (CrUX, PageSpeed Insights) utilise une session window : les layout shifts sont regroupés en fenêtres d'une seconde maximum, espacées d'au moins une seconde. Le score CLS retenu est celui de la pire session window. Cette approche, documentée par Google dans l'article de web.dev sur l'évolution du CLS, remplace l'ancien cumul brut sur toute la durée de vie de la page — ce qui pénalisait injustement les SPA avec des navigations longues.

Les seuils

  • Bon : ≤ 0.1
  • À améliorer : entre 0.1 et 0.25
  • Mauvais : > 0.25

Un score de 0.1 signifie que dans la pire session window, environ 10 % du viewport a été affecté sur une distance significative. Sur mobile (viewport de ~360×800 px), un bandeau de 80 px de haut qui pousse tout le contenu vers le bas génère à lui seul un impact fraction de 1.0 (tout le viewport est affecté) et une distance fraction de 0.1 (80/800). Score : 0.1 — juste à la limite. Ajoutez une image sans dimensions et vous passez dans le rouge.

Les 6 causes les plus fréquentes — et comment les diagnostiquer

Images et vidéos sans dimensions explicites

C'est la cause numéro un, et pourtant la plus facile à corriger. Quand le navigateur ne connaît pas les dimensions d'une image avant son chargement, il lui réserve une hauteur de 0 px. Dès que l'image se charge, tout le contenu en dessous se décale.

<!-- ❌ Génère du CLS -->
<img src="/products/sneaker-air-max.webp" alt="Nike Air Max 90">

<!-- ✅ Le navigateur réserve l'espace grâce au ratio width/height -->
<img
  src="/products/sneaker-air-max.webp"
  alt="Nike Air Max 90"
  width="600"
  height="400"
  loading="lazy"
  decoding="async"
>

<!-- ✅ Alternative CSS avec aspect-ratio (utile pour les images responsive) -->
<style>
  .product-image {
    aspect-ratio: 3 / 2;
    width: 100%;
    height: auto;
    object-fit: cover;
  }
</style>
<img
  class="product-image"
  src="/products/sneaker-air-max.webp"
  alt="Nike Air Max 90"
  loading="lazy"
  decoding="async"
>

Le point souvent négligé : les attributs width et height dans le HTML n'ont pas besoin de correspondre aux dimensions réelles de l'image. Ils servent uniquement à indiquer le ratio au navigateur. Les navigateurs modernes utilisent ces valeurs pour calculer l'aspect ratio interne de l'élément <img> avant même le début du téléchargement. Cela est documenté dans la spécification HTML de WHATWG.

Injection dynamique de bandeaux et widgets

Bandeau de consentement RGPD, barre de notification promotionnelle, widget de chat — ces éléments apparaissent après le rendu initial et poussent le contenu.

Deux stratégies :

1. Réserver l'espace en CSS avant le chargement du script :

/* Réserve 80px pour le bandeau de consentement */
.consent-placeholder {
  min-height: 80px;
}

/* Quand le bandeau est chargé, le placeholder disparaît */
.consent-loaded .consent-placeholder {
  min-height: 0;
}

2. Utiliser position: fixed ou position: sticky pour que l'élément ne participe pas au flux du document :

.cookie-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  /* Cet élément ne décale rien dans le flux */
}

Le position: fixed est la méthode la plus fiable : l'élément sort complètement du flux, donc aucun layout shift. Mais attention aux cas où le bandeau couvre du contenu interactif — cela génère des frustrations UX distinctes du CLS mais tout aussi problématiques.

Web fonts et FOUT/FOIT

Le chargement d'une police custom peut provoquer deux phénomènes : le Flash of Invisible Text (FOIT, le texte est invisible pendant le chargement) ou le Flash of Unstyled Text (FOUT, le texte apparaît en fallback puis bascule). Le FOUT génère du CLS si la police fallback et la police définitive ont des métriques différentes (hauteur de ligne, largeur des glyphes).

/* Ajuster la police fallback pour matcher les métriques de la police custom */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  /* size-adjust, ascent-override, descent-override réduisent le delta visuel */
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

Les propriétés size-adjust, ascent-override, descent-override et line-gap-override sont supportées par tous les navigateurs modernes. Elles permettent de calibrer la police fallback système pour qu'elle occupe quasiment le même espace que la police custom. Le résultat : le swap se produit toujours, mais le décalage de layout devient imperceptible (< 0.01 de CLS).

Pour calculer les bonnes valeurs, utilisez Fontaine (intégré dans Nuxt) ou le module @next/font de Next.js qui génère automatiquement ces overrides.

Contenu injecté par des iframes ou des scripts tiers

Google Ads, embeds YouTube, widgets de réseaux sociaux — ces éléments tiers ont des dimensions imprévisibles et se chargent de manière asynchrone.

<!-- ❌ L'iframe YouTube arrive sans dimensions, CLS garanti -->
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe>

<!-- ✅ Conteneur avec aspect-ratio qui réserve l'espace -->
<div style="aspect-ratio: 16 / 9; width: 100%; max-width: 720px;">
  <iframe
    src="https://www.youtube.com/embed/dQw4w9WgXcQ"
    style="width: 100%; height: 100%; border: 0;"
    loading="lazy"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
    allowfullscreen
  ></iframe>
</div>

Pour les publicités Google Ads, le problème est plus vicieux : les slots publicitaires ont des dimensions variables selon l'enchère gagnante. La seule solution fiable est de réserver une min-height correspondant à la taille d'annonce la plus fréquente pour chaque emplacement, puis de réduire cet espace si aucune annonce ne se charge (via un observer sur l'événement slotRenderEnded de GPT).

Hydration mismatch dans les frameworks SSR

Un problème spécifique aux architectures SSR/SSG avec hydration côté client : si le HTML généré côté serveur diffère de ce que React (ou Vue, Svelte) produit côté client, le framework "corrige" le DOM pendant l'hydration, ce qui peut déclencher des layout shifts massifs.

Cas typique : un composant qui affiche un contenu différent selon window.innerWidth (qui n'existe pas côté serveur). Le serveur rend la version desktop, le client corrige en version mobile — le layout entier se décale.

// ❌ Génère un hydration mismatch + CLS sur mobile
function HeroSection() {
  const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
  return isMobile ? <MobileHero /> : <DesktopHero />;
}

// ✅ Rendre le même HTML côté serveur et client, utiliser CSS pour l'adaptation
function HeroSection() {
  return (
    <>
      <div className="hidden md:block"><DesktopHero /></div>
      <div className="block md:hidden"><MobileHero /></div>
    </>
  );
}

La deuxième approche rend les deux versions dans le HTML et utilise CSS (display: none via les classes Tailwind) pour masquer celle qui ne correspond pas au viewport. Pas de mismatch, pas de correction du DOM, pas de CLS. Le coût : un peu de HTML supplémentaire dans le payload, négligeable face au gain en stabilité.

Ce type de bug est détaillé dans notre article sur les hydration mismatch et leur impact SEO. Sur les architectures SPA rendues côté client, le problème est différent mais connexe — Google peut voir une page blanche si le rendering échoue.

Chargement asynchrone de contenu above the fold

Les appels API qui injectent du contenu dans la partie visible de la page après le rendu initial sont une cause de CLS sous-estimée. Typiquement : un bloc "Produits recommandés" en haut de page, un encart "Dernières actualités", ou des avis clients chargés via une API tierce.

La solution est de remonter ces données côté serveur (SSR ou ISR) pour les inclure dans le HTML initial, ou de réserver un espace minimum si le chargement asynchrone est inévitable. Pour les sites qui utilisent ISR ou SSG, le choix du mode de rendering a un impact direct sur ce type de CLS — une question que nous détaillons dans l'article sur ISR, SSR et SSG.

Mesurer le CLS : outils et méthodologie

Données de terrain vs données de laboratoire

La distinction est critique. Les données de laboratoire (Lighthouse, PageSpeed Insights en mode "simulé") capturent le CLS pendant un chargement unique, dans des conditions contrôlées. Les données de terrain (Chrome UX Report / CrUX, intégrées dans la Search Console) reflètent l'expérience réelle des utilisateurs sur 28 jours.

Un site peut avoir un CLS Lighthouse à 0 et un CLS CrUX à 0.35. Pourquoi ? Parce que les shifts surviennent après l'interaction utilisateur (scroll, navigation SPA) ou sont causés par des scripts tiers qui ne se déclenchent pas dans l'environnement Lighthouse.

Chrome DevTools : le Performance panel

L'outil le plus précis pour diagnostiquer un layout shift spécifique :

  1. Ouvrez DevTools → onglet Performance
  2. Cochez Screenshots et Web Vitals
  3. Rechargez la page avec le profiling actif
  4. Cherchez les marqueurs Layout Shift (losanges roses) dans la timeline
  5. Cliquez sur un shift pour voir quels éléments ont bougé et de combien

La section "Summary" affiche le score du shift et les nœuds DOM affectés. C'est l'information la plus actionable : vous voyez exactement quel <div> a décalé quel autre <div>, et de combien de pixels.

La LayoutShift API en JavaScript

Pour du monitoring en production, l'API PerformanceObserver permet de capturer chaque layout shift avec son contexte :

// Monitoring CLS en production — envoie les données à votre endpoint analytics
const clsEntries = [];
let clsValue = 0;
let sessionValue = 0;
let sessionEntries = [];

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Ignorer les shifts causés par une interaction utilisateur
    if (!entry.hadRecentInput) {
      const firstSessionEntry = sessionEntries[0];
      const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

      // Si le shift appartient à la session window en cours
      if (
        sessionEntries.length > 0 &&
        entry.startTime - lastSessionEntry.startTime < 1000 &&
        entry.startTime - firstSessionEntry.startTime < 5000
      ) {
        sessionValue += entry.value;
        sessionEntries.push(entry);
      } else {
        // Nouvelle session window
        sessionValue = entry.value;
        sessionEntries = [entry];
      }

      if (sessionValue > clsValue) {
        clsValue = sessionValue;

        // Log les sources du shift pour debug
        if (entry.sources) {
          entry.sources.forEach((source) => {
            console.log('CLS source:', {
              node: source.node?.nodeName,
              selector: source.node ? getCSSSelector(source.node) : null,
              previousRect: source.previousRect,
              currentRect: source.currentRect,
            });
          });
        }
      }
    }
  }
});

observer.observe({ type: 'layout-shift', buffered: true });

// Envoyer le CLS final lors du déchargement de la page
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    navigator.sendBeacon('/api/analytics/cls', JSON.stringify({
      cls: clsValue,
      url: location.href,
      timestamp: Date.now(),
    }));
  }
});

L'attribut entry.sources (disponible dans les navigateurs Chromium) identifie les nœuds DOM responsables du shift, avec leurs positions avant/après. C'est l'équivalent programmatique de ce que vous voyez dans le Performance panel de DevTools, mais en production, sur vos vrais utilisateurs.

Search Console et CrUX

Le rapport Core Web Vitals de la Search Console regroupe vos URLs par statut (bon / à améliorer / mauvais) pour chaque métrique, dont le CLS. La limite : il ne vous dit pas quelle est la cause du shift, seulement quelles pages sont affectées.

Pour aller plus loin, interrogez directement le CrUX via l'API ou BigQuery pour segmenter par type de page, device, ou période :

# Requête CrUX API pour une origine spécifique
curl "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "origin": "https://www.votre-ecommerce.fr",
    "formFactor": "PHONE",
    "metrics": ["cumulative_layout_shift"]
  }'

Cette commande retourne la distribution du CLS (p75) pour les utilisateurs mobiles de votre domaine. Si le p75 dépasse 0.1, vous avez un problème qui touche au moins 25 % de vos utilisateurs.

Scénario concret : un média en ligne de 8 000 pages avec un CLS catastrophique

Un site d'information avec 8 000 articles, construit sur Next.js 14 en mode SSR, affiche un CLS p75 de 0.32 sur mobile dans la Search Console. Le trafic organique stagne depuis 3 mois malgré un contenu régulier et de qualité. L'audit révèle que les Core Web Vitals ont un impact mesurable sur le classement, et ce site échoue sur une des trois métriques.

Diagnostic

L'équipe SEO utilise Screaming Frog pour identifier les templates de page (article, catégorie, auteur, tag) et croise avec les données CrUX par groupe d'URL dans la Search Console. Résultat : seules les pages article sont en rouge. Les pages catégorie et auteur sont en vert.

Analyse dans Chrome DevTools (Performance panel) sur 5 articles représentatifs :

  1. Shift #1 (score 0.12) : Un bloc publicitaire AdSense en haut de page (entre le titre H1 et le premier paragraphe) n'avait pas d'espace réservé. L'ad se charge 1.2s après le DOM initial et pousse tout le contenu de 250 px vers le bas.

  2. Shift #2 (score 0.08) : Les images éditoriales dans le corps de l'article utilisaient next/image avec fill sans conteneur à dimensions fixes. Le composant Image avec fill attend que le conteneur parent définisse une taille — sans ça, le navigateur ne peut pas réserver l'espace.

  3. Shift #3 (score 0.06) : Un embed Twitter injecté via le script widgets.js de la plateforme X, sans conteneur dimensionné.

Score CLS cumulé dans la pire session window : environ 0.26 (les shifts #1 et #2 tombent dans la même fenêtre d'une seconde).

Corrections appliquées

Pour le slot publicitaire : réservation d'un min-height: 280px (taille leaderboard standard) sur le conteneur de l'ad, avec un collapse après 3 secondes si aucune ad n'est remplie :

.ad-slot-top {
  min-height: 280px;
  background-color: #f5f5f5; /* indication visuelle subtile */
  transition: min-height 0.3s ease;
}

.ad-slot-top.ad-empty {
  min-height: 0;
}
// Collapse le slot si aucune pub ne se charge après 3s
googletag.pubads().addEventListener('slotRenderEnded', (event) => {
  if (event.isEmpty) {
    event.slot.getSlotElementId()
    const slotEl = document.getElementById(event.slot.getSlotElementId());
    slotEl?.classList.add('ad-empty');
  }
});

// Fallback timeout si le callback ne se déclenche jamais
setTimeout(() => {
  document.querySelectorAll('.ad-slot-top:not(.ad-loaded)').forEach((el) => {
    el.classList.add('ad-empty');
  });
}, 3000);

Pour les images : ajout d'un conteneur avec aspect-ratio autour de chaque next/image en mode fill :

// Composant image article avec espace réservé
function ArticleImage({ src, alt, width, height }: ArticleImageProps) {
  return (
    <div
      style={{
        position: 'relative',
        width: '100%',
        aspectRatio: `${width} / ${height}`,
      }}
    >
      <Image
        src={src}
        alt={alt}
        fill
        sizes="(max-width: 768px) 100vw, 720px"
        style={{ objectFit: 'cover' }}
      />
    </div>
  );
}

Pour les embeds Twitter : remplacement du script widgets.js par un placeholder statique avec un lien vers le tweet, chargé en lazy loading uniquement quand l'utilisateur scrolle à proximité (Intersection Observer). Le CLS tombe à zéro puisque le contenu dynamique n'apparaît que suite à un scroll (interaction implicite non comptabilisée par Chrome comme "recent input", mais le shift survient bien après le chargement initial, dans une session window isolée et de faible amplitude).

Résultat

Quatre semaines après déploiement, le CLS p75 mobile dans CrUX passe de 0.32 à 0.04. La Search Console requalifie les 8 000 pages article en "Bon" pour le CLS. Sur les 6 semaines suivantes, le trafic organique mobile augmente de 11 % — corrélation, pas causalité prouvée, mais cohérent avec le fait que ces pages étaient les seules à échouer sur les Core Web Vitals.

Automatiser la détection des régressions CLS

Corriger le CLS une fois ne suffit pas. Un développeur qui ajoute un nouveau composant, un tag manager qui injecte un script tiers, une mise à jour de lib UI qui modifie le CSS — chacune de ces actions peut réintroduire des layout shifts.

Intégration dans la CI/CD

Lighthouse CI permet de vérifier le CLS à chaque pull request :

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

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/cli
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

# lighthouserc.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: [
        'http://localhost:3000/',
        'http://localhost:3000/categorie/chaussures',
        'http://localhost:3000/produit/air-max-90',
      ],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
      },
    },
  },
};

Cela bloque le merge si le CLS dépasse 0.1 sur les URLs testées. Limite évidente : Lighthouse CI mesure en conditions de laboratoire, sur un set limité d'URLs. Il ne capture pas les shifts causés par des scripts tiers chargés en production uniquement.

Monitoring continu en production

Pour les sites à fort volume de pages, le monitoring de laboratoire par CI ne suffit pas. Vous avez besoin de surveiller les données de terrain en continu et d'être alerté quand le CLS se dégrade sur un template ou un groupe de pages. C'est exactement le type de régression qu'un outil de monitoring comme Seogard détecte automatiquement — une dégradation du CLS sur vos pages produit un mardi à 14h, corrélée avec un déploiement, repérée avant que les données CrUX sur 28 jours ne reflètent le problème dans la Search Console.

Edge cases et trade-offs à connaître

Le CLS et les SPA avec navigation client-side

Dans une Single Page Application, les changements de route côté client génèrent des layout shifts qui sont comptabilisés dans le CLS — puisque la "page" au sens Chrome ne change pas. La session window de 5 secondes maximum limite l'impact, mais une SPA avec des transitions de page mal gérées peut accumuler un CLS élevé.

Si vous êtes sur une architecture SPA et que Google voit des résultats différents de vos utilisateurs, le choix du mode de rendering influence directement ce que Googlebot mesure. Le prerendering peut résoudre le problème pour le crawl, mais pas pour les utilisateurs réels.

Le CLS n'est pas toujours un problème SEO

Si votre CLS p75 est à 0.15 dans CrUX, vous êtes dans la zone "à améliorer", pas "mauvais". L'impact SEO marginal d'un CLS à 0.15 vs 0.08 est probablement négligeable pour la plupart des requêtes — la pertinence du contenu domine. Investissez votre temps là où le ROI est le plus élevé : si votre LCP est à 5 secondes ou votre INP à 400 ms, corrigez ces métriques d'abord.

Animations CSS et transforms

Les animations basées sur transform et opacity ne déclenchent pas de layout shift — elles sont gérées par le compositing thread, pas par le layout engine. Utilisez transform: translateY() plutôt que top ou margin-top pour les animations d'entrée d'éléments.

En revanche, une animation qui modifie height, width, padding, ou margin déclenche un recalcul de layout et potentiellement un CLS si elle survient sans interaction utilisateur préalable.

Le piège du font-display: optional

font-display: optional élimine tout FOUT — si la police n'est pas en cache, le navigateur ne la swap jamais et reste sur la fallback. Zéro CLS garanti. Mais le compromis : lors de la première visite, vos utilisateurs voient la police système. Pour un site où la typographie est un élément de marque fort, c'est un trade-off à peser. font-display: swap avec des override métriques bien calibrés est souvent le meilleur équilibre entre CLS et fidélité visuelle.

Les meta tags ne sont pas épargnées

Un cas rarement évoqué : les balises meta viewport mal configurées peuvent aggraver les layout shifts perçus. Un <meta name="viewport"> sans width=device-width laisse le navigateur deviner le viewport, ce qui peut déclencher un re-layout initial. Pour les erreurs fréquentes sur les meta tags en général — y compris leur impact indirect sur le rendering et donc le CLS — consultez le guide complet des meta tags SEO.

Ce qu'il faut retenir

Le CLS est la métrique Core Web Vitals la plus traître : elle semble simple (les éléments bougent), mais sa résolution exige de comprendre le cycle de rendu du navigateur, les interactions entre scripts tiers et layout engine, et les spécificités de votre framework. Chaque template de page mérite un audit dédié, et chaque déploiement peut réintroduire une régression. La seule approche fiable combine des corrections structurelles (dimensions explicites, espace réservé, CSS transforms), des garde-fous en CI (Lighthouse CI), et un monitoring de terrain continu pour détecter les dégradations avant qu'elles n'affectent votre classement.

Articles connexes

Performance5 avril 2026

Core Web Vitals : impact réel sur le classement Google

Analyse technique des Core Web Vitals (LCP, CLS, INP) et leur influence mesurable sur le ranking Google. Données, cas concrets et optimisations.

Performance5 avril 2026

LCP lent : diagnostiquer et corriger le Largest Contentful Paint

Techniques concrètes pour diagnostiquer et corriger un LCP lent : images, fonts, TTFB, preload, CDN. Exemples de code et scénarios réels.

Performance5 avril 2026

INP : diagnostiquer et corriger une Interaction to Next Paint lente

Guide technique pour comprendre, mesurer et optimiser INP. Stratégies JavaScript concrètes pour passer sous le seuil des 200ms.