Zapobieganie CLS: stabilność wizualna i debugowanie zmiany układu
Czytasz artykuł. Za chwilę klikniesz interesujący link. Nagle zawartość kliknie w dół o 200 pikseli: pojawia się baner reklamowy powyżej. Kliknąłeś na zły link. Zjawisko to – tzw zmiana układu — i dokładnie, co Łączne przesunięcie układu (CLS) mierzyć. Google uważa to za jeden z trzech najważniejszych wskaźników internetowych wpływających na wygodę użytkownika.
CLS poniżej 0,1 uważa się za „dobry”; od 0,1 do 0,25 „do poprawy”; powyżej 0,25 „rzadkie”. Dobra wiadomość: w przeciwieństwie do LCP (który wymaga optymalizacji sieci i serwer), CLS jest prawie całkowicie zależny od CSS i HTML. Z właściwymi technikami możesz doprowadzić CLS do zera.
Czego się nauczysz
- Jak przeglądarka oblicza CLS: ułamek uderzenia, ułamek odległości, wynik
- Zidentyfikuj przyczyny CLS za pomocą regionów zmiany układu w narzędziach Chrome DevTools
- Obrazy bezwymiarowe: winowajca numer jeden
- współczynnik proporcji CSS: rezerwuj miejsce przed załadowaniem obrazu
- Zamiana czcionek CLS: wyświetlanie czcionek i dostosowywanie rozmiaru dla wskaźników zastępczych
- Dynamicznie wstrzykiwana treść: reklamy banerowe, zgoda na pliki cookie, ekrany szkieletowe
- Animacje powodujące CLS: transformacja vs góra/lewo
Jak obliczany jest CLS
CLS nie mierzy, ile przesunięć następuje, ale jak bardzo „przeszkadzają” one użytkownikowi. Każda zmiana układu daje wynik obliczony jako:
wynik przesunięcia układu = ułamek uderzenia × ułamek odległości
L'frakcja uderzeniowa i procent rzutni, na który „wpływa” ze zmiany (powierzchnia zajęta przed + powierzchnia zajęta po). Tam ułamek odległości oraz maksymalną odległość przebytą przez dowolny dotknięty przedmiot, jako ułamek rozmiaru rzutni. Ostateczny CLS jest sumą wszystkich zmian wystąpiło w oknach sesji trwających 5 sekund, przyjmując maksimum.
// Misura il CLS con PerformanceObserver
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Ignora shift causati dall'interazione utente (scroll, click)
// Gli shift da input utente non vengono penalizzati
if (!entry.hadRecentInput) {
console.log('Layout Shift detected:', {
score: entry.value.toFixed(4),
time: Math.round(entry.startTime),
sources: entry.sources?.map((source) => ({
element: source.node?.tagName,
elementId: source.node?.id,
previousRect: source.previousRect,
currentRect: source.currentRect,
})),
});
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
// Calcola il CLS cumulativo (sessione finestra da 5s)
let clsScore = 0;
let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
const firstEntry = sessionEntries[0];
const lastEntry = sessionEntries[sessionEntries.length - 1];
if (
sessionEntries.length === 0 ||
entry.startTime - lastEntry.startTime < 1000 &&
entry.startTime - firstEntry.startTime < 5000
) {
sessionEntries.push(entry);
sessionValue += entry.value;
} else {
clsScore = Math.max(clsScore, sessionValue);
sessionEntries = [entry];
sessionValue = entry.value;
}
}
}
clsScore = Math.max(clsScore, sessionValue);
console.log('Current CLS:', clsScore.toFixed(4));
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
Obrazy bezwymiarowe: główny winowajca
Najczęstszy przypadek wysokiego CLS: obrazy bez atrybutów width
e height. Przeglądarka nie wie, ile miejsca zarezerwować na obraz
przed załadowaniem, więc tekst jest renderowany jako pierwszy i zajmuje miejsce,
następnie obraz, gdy się pojawi, spycha wszystko w dół.
/* CSS: l'aspect-ratio e implicito dagli attributi HTML */
img {
width: 100%; /* si adatta al container */
height: auto; /* mantiene l'aspect-ratio automaticamente */
/* Il browser usa width/height HTML per calcolare lo spazio da riservare */
}
/* aspect-ratio CSS: riserva spazio per qualsiasi contenuto */
/* Contenitore immagine con aspect-ratio 16:9 */
.image-container {
width: 100%;
aspect-ratio: 16 / 9; /* Riserva lo spazio prima del caricamento */
background: #f0f0f0; /* Placeholder visivo */
overflow: hidden;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Per contenuto embedded (iframe, video, mappe) */
.video-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.video-wrapper iframe,
.video-wrapper video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* Skeleton screen: placeholder animato che riserva lo spazio corretto */
.skeleton {
aspect-ratio: 16 / 9;
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Zamiana czcionek CLS: regulacja rozmiaru i metryki zastępcze
Kiedy czcionka internetowa jest ładowana i zastępuje czcionkę systemową, tekst
może zmienić rozmiar i spowodować zmianę układu. size-adjust,
ascent-override, descent-override e line-gap-override
umożliwiają kalibrację wskaźników czcionek zastępczych w celu ograniczenia lub wyeliminowania
ta zmiana.
/* Minimizzare il CLS da font swap con @font-face overrides */
/* Passo 1: misura le metriche del tuo web font */
/* Usa: https://screenspan.net/fallback o il Chrome DevTools > Fonts */
/* Passo 2: crea un @font-face per il fallback calibrato */
@font-face {
font-family: 'Inter-Fallback';
src: local('Arial'); /* Font di sistema disponibile ovunque */
/* Valori per avvicinare Arial alle metriche di Inter */
/* (calcolati con fontaine o screenspan.net) */
ascent-override: 90.20%;
descent-override: 22.48%;
line-gap-override: 0%;
size-adjust: 107.40%;
}
/* Passo 3: usa il fallback nella font-family stack */
body {
font-family: 'Inter', 'Inter-Fallback', system-ui, sans-serif;
}
/* Risultato: quando Inter caricherà, il layout quasi non cambierà
perché Arial è stato ridimensionato per avere le stesse metriche */
/* font-display: optional per zero FOIT e CLS minimo */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-400.woff2') format('woff2');
font-weight: 400;
font-display: optional;
/* Se il font non è in cache al primo caricamento:
usa il fallback per sempre (zero swap = zero CLS)
Dal secondo caricamento: font in cache, usato immediatamente */
}
Dynamicznie wstrzykiwana zawartość
Banery reklamowe, zgoda na pliki cookie, powiadomienia push, treści ładowane poprzez pobieranie: cała zawartość, która jest wstrzykiwana do DOM po wstępnym renderowaniu, może spowodować zmianę układu, jeśli miejsce nie zostało wcześniej zarezerwowane.
/* Riserva spazio per banner pubblicitari e cookie consent */
/* Banner pubblicitari: usa min-height per riservare spazio */
.ad-slot {
min-height: 90px; /* Dimensione standard banner leaderboard */
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
/* Cookie consent banner: posiziona in modo non-intrusivo */
.cookie-consent {
position: fixed; /* fixed non causa layout shift sul contenuto */
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
/* Non usa position: sticky o relative che potrebbero shiftare il contenuto */
}
/* Contenuto lazy-loaded: schermata skeleton con dimensione fissa */
.lazy-content {
min-height: 200px; /* Riserva spazio prima del caricamento */
}
.lazy-content.loaded {
min-height: auto; /* Rimuovi dopo il caricamento se il contenuto è più alto */
}
/* SBAGLIATO: inserire contenuto in alto nella pagina */
/* Evita di aggiungere elementi PRIMA del contenuto gia visibile */
/* CORRETTO: usa position: fixed o bottom per contenuto dinamico */
.notification-bar {
position: fixed; /* Non sposta il contenuto esistente */
top: 0;
left: 0;
right: 0;
}
Animacje powodujące CLS
Nie wszystkie animacje powodują CLS. Animacje, których używają transform
e opacity Dzieje się tak w wątku kompozytora i nie powodują zmian w układzie.
Zmieniające się animacje top, left, width,
height, margin, padding powodują ponowny przepływ
i mogą przyczynić się do CLS, jeśli wystąpią bez interakcji użytkownika.
/* Animazioni CLS-safe: usa solo transform e opacity */
/* SBAGLIATO: anima proprieta che causano reflow */
.slide-in-bad {
animation: slideInBad 0.3s ease;
}
@keyframes slideInBad {
from { top: -100px; } /* causa layout reflow */
to { top: 0; }
}
/* CORRETTO: usa transform (compositor-only, nessun layout reflow) */
.slide-in-good {
animation: slideInGood 0.3s ease;
}
@keyframes slideInGood {
from { transform: translateY(-100px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Accordion / Expand-Collapse: usa max-height con attenzione */
/* max-height puo causare CLS se l'elemento e above-the-fold */
.accordion-content {
overflow: hidden;
/* Usa Grid per animazioni smooth senza max-height */
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
/* Tooltip e overlay: position absolute/fixed mai relative */
.tooltip {
position: absolute; /* Non sposta il contenuto circostante */
z-index: 1000;
pointer-events: none;
}
/* Usa will-change con parsimonia per ottimizzare animazioni pesanti */
.heavy-animation {
will-change: transform;
/* Solo se l'animazione e effettivamente pesante,
will-change crea un nuovo layer che usa piu memoria */
}
Debugowanie CLS za pomocą Chrome DevTools
Chrome DevTools oferuje specjalne narzędzia umożliwiające identyfikację dokładnej przyczyny każdej zmiany układu. Najbardziej skuteczna metoda:
// Step-by-step: debug CLS in Chrome DevTools
// 1. Apri DevTools > Performance
// 2. Clicca "Record" e ricarica la pagina
// 3. Ferma la registrazione dopo il caricamento completo
// 4. Nella timeline, cerca il marker "Layout Shift" (barra rossa)
// 5. Clicca su un Layout Shift entry per vedere:
// - "Sources": quali elementi si sono spostati
// - "Score": il contributo di questo shift al CLS
// - "Had Recent Input": se causato da interazione utente (non conta)
// 6. Usa "Layout Shift Regions" checkbox nella barra superiore
// per visualizzare in overlay gli elementi che si spostano
// Oppure usa questa snippet nella Console:
document.addEventListener('DOMContentLoaded', () => {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput && entry.value > 0) {
const elements = entry.sources
?.map((s) => s.node)
.filter(Boolean) ?? [];
// Evidenzia in rosso gli elementi che causano shift
elements.forEach((el) => {
if (el instanceof HTMLElement) {
el.style.outline = '3px solid red';
el.title = `CLS: ${entry.value.toFixed(4)}`;
setTimeout(() => {
el.style.outline = '';
}, 3000);
}
});
console.warn('CLS shift:', entry.value.toFixed(4), elements);
}
}
});
obs.observe({ type: 'layout-shift', buffered: true });
});
Lista kontrolna CLS: Przesunięcie układu zerowego
- Wszystkie obrazy mają atrybuty szerokości i wysokości (lub współczynnik proporcji za pomocą CSS)
- Czcionki używają wyświetlania czcionek: zamień lub opcjonalnie ze skalibrowanym powrotem
- Reklamy banerowe mają zarezerwowaną przestrzeń o minimalnej wysokości
- Pozycja zgody na użycie plików cookie i powiadomień: stała
- Animacje wykorzystują tylko transformację i krycie (nie góra/lewo/szerokość)
- Treść ładowana leniwie ma symbol zastępczy o równoważnym rozmiarze
- Sprawdź CLS w Chrome DevTools na telefonie komórkowym (emulacja 4G)
Wnioski
CLS to najbardziej kontrolowany wskaźnik Core Web Vitals: w przeciwieństwie do LCP (który zależy od sieci i serwera) oraz INP (który zależy od złożoności pliku JavaScript), CLS zależy prawie całkowicie od wyborów CSS i HTML. Z wymiarami wyraźne na obrazach, zoptymalizowane wyświetlanie czcionek i zarządzana dynamiczna zawartość przy ustalonej pozycji możesz ustawić CLS na zero w dowolnym typie witryny.







