Vous lancez Lighthouse sur une page qui s'affiche normalement, avec un titre, une image principale et du texte. Le rapport échoue pourtant sur l'erreur NO_LCP : la page n'aurait affiché aucun élément éligible au Largest Contentful Paint. Vous relancez et l'erreur revient, ou elle disparaît une fois sur deux. Dans la Search Console, la page n'a pas non plus de données LCP propres.
J'ai rencontré ce cas sur un site en production. Le coupable n'était ni l'image principale, ni le serveur, ni un script tiers. C'était une seule ligne de CSS, placée sur un carrousel situé tout en bas de la page :
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}Voici pourquoi cette ligne peut empêcher la mesure du LCP, comment confirmer le diagnostic en quelques minutes, et comment corriger sans renoncer au carrousel.
Le LCP s'arrête à la première interaction
Le LCP n'est pas mesuré une seule fois. Pendant le chargement, le navigateur émet une suite de candidats : d'abord un titre, puis un bloc de texte plus grand, puis l'image principale. Le dernier candidat émis devient le LCP de la page.
Cette suite a une fin. Dès que l'utilisateur interagit avec la page (clic, touche du clavier ou défilement), Chrome cesse d'émettre de nouveaux candidats. C'est logique : après une interaction, ce qui s'affiche ne reflète plus le chargement initial.
Si cet arrêt intervient avant que le premier candidat ait été peint, la page n'a aucun LCP. Lighthouse renvoie alors NO_LCP, et les données terrain (CrUX) de la visite n'ont pas de valeur LCP.
À retenir : sur un chargement normal, personne ne fait défiler la page dans les premières centaines de millisecondes, surtout pas Lighthouse. Un NO_LCP signifie donc souvent qu'un défilement s'est produit sans que personne ne touche à la page.
Pourquoi mandatory provoque un défilement tout seul
scroll-snap-type: x mandatory impose au conteneur d'être toujours calé sur un point d'accroche (scroll-snap-align). Avec proximity, le navigateur ne cale que si la position est proche d'un point. Avec mandatory, il n'a pas le choix : aucune position intermédiaire n'est autorisée.
La spécification CSS Scroll Snap prévoit aussi le re-snap. Quand le contenu ou la mise en page d'un conteneur à accroche change, le navigateur doit le recaler sur un point d'accroche. Or, pendant le chargement, la mise en page change sans cesse :
- une police web remplace la police de secours, et la largeur des cartes varie de quelques pixels ;
- une image sans dimensions réservées se charge et agrandit sa carte ;
- un composant JavaScript s'initialise et modifie les styles du carrousel ;
- des arrondis de sous-pixels (largeurs en
%,gap,vw) décalent l'accroche d'une fraction de pixel.
À chaque changement, le carrousel est recalé, parfois d'un pixel ou moins. Ce recalage est un vrai défilement horizontal du conteneur. Dans le cas que j'ai analysé, Chrome l'a traité comme une interaction et a arrêté la mesure du LCP pour toute la page, alors que le carrousel était à plusieurs milliers de pixels sous la ligne de flottaison.
Le problème est intermittent parce que tout dépend de l'ordre des événements. Si le candidat LCP est peint avant le premier recalage, il n'y a pas de souci. Si le recalage passe devant, l'erreur NO_LCP apparaît. Lighthouse en mobile simule un processeur 4 fois plus lent, ce qui retarde la peinture et favorise ce second cas. Les smartphones d'entrée de gamme de vos vrais visiteurs se trouvent dans la même situation.
Confirmer le diagnostic en deux minutes
Avant de modifier le CSS, vérifiez l'hypothèse. Collez ce code dans la console de Chrome, puis rechargez la page avec l'onglet Sources > Snippets, ou bien placez-le temporairement en tête du <head> :
// 1. Afficher chaque candidat LCP au moment où il est émis
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('[LCP] candidat', Math.round(entry.startTime) + ' ms', entry.element);
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
// 2. Repérer les défilements, y compris ceux des conteneurs internes
document.addEventListener('scroll', (event) => {
const target = event.target === document ? 'document' : event.target;
console.log('[SCROLL]', Math.round(performance.now()) + ' ms', target);
}, { capture: true, passive: true });L'écouteur est posé en phase de capture sur document. Les événements scroll des éléments internes ne remontent pas jusqu'au document, mais la capture permet de les intercepter quand même.
Pour reproduire les conditions de Lighthouse, activez dans l'onglet Performance le ralentissement CPU 4x et un réseau Slow 4G. Si une ligne [SCROLL] pointant vers votre carrousel s'affiche sans que vous ayez touché à la page, et qu'aucun [LCP] ne la précède ou ne la suit, le diagnostic est confirmé.
Autres causes possibles d'un défilement fantôme :element.scrollIntoView() ou focus() appelés au chargement, un lien d'ancre (#section) dans l'URL, la restauration de la position de défilement, ou un script de carrousel qui positionne la première diapositive avec scrollLeft. Le même code de diagnostic les fait apparaître aussi.
Les corrections, de la plus rapide à la plus robuste
1. Passer en proximity
C'est le changement le plus simple :
.carousel {
scroll-snap-type: x proximity;
}Avec proximity, le navigateur n'est plus obligé de recaler le conteneur à chaque changement de mise en page. Le comportement au geste devient en revanche plus souple : sur mobile, un balayage peut s'arrêter entre deux cartes. Selon le design, c'est acceptable ou non. Ce n'est pas non plus une garantie absolue, car un recalage reste possible si la position est déjà proche d'un point d'accroche.
2. Stabiliser la mise en page du carrousel
Sans changement de mise en page, il n'y a pas de recalage. Il faut donc que les cartes aient leur taille définitive dès le premier rendu :
.carousel__item {
flex: 0 0 300px; /* largeur fixe plutôt qu'un pourcentage */
scroll-snap-align: start;
}
.carousel__item img {
aspect-ratio: 4 / 3; /* hauteur réservée avant le chargement */
width: 100%;
height: auto;
}Ajoutez des attributs width et height sur les images, et limitez l'écart entre la police web et la police de secours (size-adjust dans @font-face, ou préchargement de la police). C'est bénéfique pour le CLS de toute façon, mais il est difficile d'éliminer tous les micro-décalages, en particulier les arrondis de sous-pixels.
3. N'activer l'accroche qu'au moment où elle sert (la solution que je recommande)
Le scroll-snap n'est utile que lorsque le visiteur fait défiler le carrousel. Il n'y a donc aucune raison qu'il soit actif pendant le chargement, quand le carrousel est hors écran. On sort l'accroche dans une classe :
.carousel {
display: flex;
overflow-x: auto;
}
.carousel.is-snap-ready {
scroll-snap-type: x mandatory;
}Un IntersectionObserver ajoute ensuite cette classe quand le carrousel approche de l'écran :
const carousels = document.querySelectorAll('.carousel');
const snapObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add('is-snap-ready');
observer.unobserve(entry.target);
}
}, { rootMargin: '200px 0px' });
carousels.forEach((carousel) => snapObserver.observe(carousel));Cette approche a plusieurs avantages :
- Aucun recalage pendant le chargement : sans accroche, les changements de mise en page ne provoquent pas de défilement ;
- Comportement inchangé pour l'utilisateur : la marge de 200 px active l'accroche avant que le carrousel soit visible, il garde donc le comportement
mandatory; - Amélioration progressive : si le JavaScript ne se charge pas, le carrousel reste utilisable, il défile simplement sans accroche ;
- Coût négligeable : un seul observateur, qui se déconnecte de chaque carrousel après usage.
Carrousel visible au chargement ? Si le carrousel est en haut de page, l'observateur se déclenche immédiatement et le problème peut revenir. Dans ce cas, activez plutôt l'accroche après l'événement load ou au premier pointerdown sur le carrousel, et soignez la stabilisation décrite au point 2.
Vérifier la correction
- En local : relancez le code de diagnostic. Plus aucune ligne
[SCROLL]ne doit apparaître au chargement, et le dernier[LCP]doit pointer vers l'élément attendu. - Lighthouse : lancez plusieurs mesures d'affilée en mode mobile. Comme le problème était intermittent, une seule mesure réussie ne prouve rien.
- Données terrain : CrUX agrège les 28 derniers jours. Il faut donc environ un mois pour que PageSpeed Insights et la Search Console reflètent entièrement la correction.
Ce qu'il faut retenir
Un NO_LCP sur une page qui s'affiche correctement ne vient presque jamais du contenu principal. Le plus souvent, quelque chose a défilé avant que le LCP soit peint. Le scroll-snap en mandatory est un suspect sérieux, car il est conçu pour recaler son conteneur tout seul, y compris loin sous la ligne de flottaison et en dehors de tout geste de l'utilisateur.
Le bon réflexe est de repérer les défilements fantômes avec un PerformanceObserver et un écouteur scroll en capture, puis de n'activer l'accroche qu'au moment où elle sert. Une ligne de CSS déplacée et une dizaine de lignes de JavaScript suffisent à rendre au navigateur la mesure du LCP.