Layout shift tracking
Chrome 77+ Edge 79+ Firefox Safari Details
Tracks all layout shifts during page load and user interaction, identifying the elements causing Cumulative Layout Shift (CLS) (opens in a new tab). This debugging-focused snippet logs every shift with detailed information about which elements moved and when.
Why this matters
While the CLS quick check gives you a score, this snippet helps you debug which specific elements are causing layout shifts. It shows you the exact selectors, movement distances, and whether shifts were excluded due to user input. This is essential for fixing CLS issues.
CLS rating thresholds
| Rating | CLS Score | Meaning |
|---|---|---|
| Good | ≤ 0.1 | Minimal, stable layout |
| Needs Improvement | ≤ 0.25 | Noticeable shifting |
| Poor | > 0.25 | Significant layout instability |
What causes layout shifts
| Cause | Example | Solution |
|---|---|---|
| Images without dimensions | <img src="..."> | Add width and height attributes |
| Ads/embeds without reserved space | Dynamic ad injection | Reserve space with CSS |
| Dynamically injected content | Banners, notifications | Reserve space or use transforms |
| Web fonts causing FOIT/FOUT | Font swap changes text size | Use font-display: optional or size-adjust |
| DOM updates above viewport | Content inserted above current scroll | Insert below or use transforms |
Note: Shifts with hadRecentInput: true (within 500ms of user input) are excluded from CLS, as they're considered expected by the user.
Layout shift phases
CLS counting logic
Snippet
// Layout Shift Tracking
// https://webperf-snippets.nucliweb.net
(async () => {
if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
console.warn("⚠️ layout-shift entries are not supported in this browser.");
return { script: "Layout-Shift-Loading-and-Interaction", status: "unsupported", error: "layout-shift entries not supported in this browser" };
}
// CLS thresholds
const valueToRating = (score) =>
score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
const RATING_COLORS = {
good: "#0CCE6A",
"needs-improvement": "#FFA400",
poor: "#FF4E42",
};
const RATING_ICONS = {
good: "🟢",
"needs-improvement": "🟡",
poor: "🔴",
};
// Track all shifts. CLS is the largest session window: shifts less than 1s apart,
// in a window of at most 5s.
let totalCLS = 0;
let sessionValue = 0;
let sessionFirst = null;
let sessionLast = null;
const allShifts = [];
const elementShifts = new Map(); // Track shifts per element
// The five elements that shifted the most (same value at start and in the summary)
const getTopElements = () =>
Array.from(elementShifts.entries())
.sort((a, b) => b[1].totalShift - a[1].totalShift)
.slice(0, 5)
.map(([selector, data]) => ({
selector,
shiftCount: data.count,
totalImpact: Math.round(data.totalShift * 10000) / 10000,
}));
const getElementSelector = (element) => {
if (!element) return "(unknown)";
if (element.id) return `#${element.id}`;
if (element.className && typeof element.className === "string") {
const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
}
return element.tagName?.toLowerCase() || "(unknown)";
};
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only count shifts without recent input for CLS
const countedForCLS = !entry.hadRecentInput;
if (countedForCLS) {
if (
sessionLast &&
entry.startTime - sessionLast.startTime < 1000 &&
entry.startTime - sessionFirst.startTime < 5000
) {
sessionValue += entry.value;
} else {
sessionValue = entry.value;
sessionFirst = entry;
}
sessionLast = entry;
totalCLS = Math.max(totalCLS, sessionValue);
}
// Get affected elements
const sources = entry.sources || [];
const elements = sources.map((source) => ({
element: source.node,
selector: getElementSelector(source.node),
previousRect: source.previousRect,
currentRect: source.currentRect,
}));
// Track per-element shifts
elements.forEach((el) => {
if (!elementShifts.has(el.selector)) {
elementShifts.set(el.selector, { count: 0, totalShift: 0, element: el.element });
}
const data = elementShifts.get(el.selector);
data.count++;
data.totalShift += entry.value;
});
const shift = {
value: entry.value,
countedForCLS,
elements,
time: entry.startTime,
entry,
};
allShifts.push(shift);
// Log significant shifts (> 0.001)
if (entry.value > 0.001) {
const rating = valueToRating(totalCLS);
const icon = countedForCLS ? RATING_ICONS[rating] : "⚪";
const clsColor = RATING_COLORS[rating];
console.groupCollapsed(
`%c${icon} Layout Shift: ${entry.value.toFixed(4)}${countedForCLS ? "" : " (not counted - user input)"}`,
`font-weight: bold; color: ${countedForCLS ? clsColor : "#888"};`
);
console.log(`%cCurrent CLS: ${totalCLS.toFixed(4)}`, `color: ${clsColor}; font-weight: bold;`);
console.log(` Time: ${Math.round(entry.startTime)}ms`);
console.log(` Had recent input: ${entry.hadRecentInput ? "Yes (excluded from CLS)" : "No"}`);
if (elements.length > 0) {
console.log("");
console.log("%cElements that shifted:", "font-weight: bold;");
elements.forEach((el, i) => {
console.log(` ${i + 1}. ${el.selector}`, el.element);
if (el.previousRect && el.currentRect) {
const dx = el.currentRect.x - el.previousRect.x;
const dy = el.currentRect.y - el.previousRect.y;
const dw = el.currentRect.width - el.previousRect.width;
const dh = el.currentRect.height - el.previousRect.height;
const changes = [];
if (Math.abs(dx) > 0) changes.push(`x: ${dx > 0 ? "+" : ""}${Math.round(dx)}px`);
if (Math.abs(dy) > 0) changes.push(`y: ${dy > 0 ? "+" : ""}${Math.round(dy)}px`);
if (Math.abs(dw) > 0) changes.push(`width: ${dw > 0 ? "+" : ""}${Math.round(dw)}px`);
if (Math.abs(dh) > 0) changes.push(`height: ${dh > 0 ? "+" : ""}${Math.round(dh)}px`);
if (changes.length > 0) {
console.log(` Movement: ${changes.join(", ")}`);
}
}
});
} else {
console.log(" (No source elements captured)");
}
console.groupEnd();
}
}
});
observer.observe({ type: "layout-shift", buffered: true });
// Summary function
window.getLayoutShiftSummary = () => {
console.group("%c📊 Layout Shift Summary", "font-weight: bold; font-size: 14px;");
const rating = valueToRating(totalCLS);
const icon = RATING_ICONS[rating];
const color = RATING_COLORS[rating];
// CLS Score
console.log("");
console.log("%cCLS Score:", "font-weight: bold;");
console.log(
` ${icon} %c${totalCLS.toFixed(4)} (${rating})`,
`color: ${color}; font-weight: bold; font-size: 16px;`
);
// Statistics
const countedShifts = allShifts.filter((s) => s.countedForCLS);
const excludedShifts = allShifts.filter((s) => !s.countedForCLS);
console.log("");
console.log("%cStatistics:", "font-weight: bold;");
console.log(` Total shift events: ${allShifts.length}`);
console.log(` Counted for CLS: ${countedShifts.length}`);
console.log(` Excluded (user input): ${excludedShifts.length}`);
if (countedShifts.length > 0) {
const largest = Math.max(...countedShifts.map((s) => s.value));
console.log(` Largest single shift: ${largest.toFixed(4)}`);
}
// Top shifting elements
if (elementShifts.size > 0) {
console.log("");
console.log("%c🎯 Top Shifting Elements:", "font-weight: bold; color: #ef4444;");
const sortedElements = Array.from(elementShifts.entries())
.sort((a, b) => b[1].totalShift - a[1].totalShift)
.slice(0, 5);
const tableData = sortedElements.map(([selector, data]) => ({
Element: selector,
"Shift Count": data.count,
"Total Impact": data.totalShift.toFixed(4),
}));
console.table(tableData);
console.log("");
console.log("%c🔎 Inspect elements:", "font-weight: bold;");
sortedElements.forEach(([selector, data], i) => {
console.log(` ${i + 1}. ${selector}`, data.element);
});
}
// Timeline
if (countedShifts.length > 0) {
console.log("");
console.log("%c⏱️ Shift Timeline:", "font-weight: bold;");
const significant = countedShifts.filter((s) => s.value > 0.001);
if (significant.length > 0) {
const timelineData = significant.map((s) => ({
"Time (ms)": Math.round(s.time),
Value: s.value.toFixed(4),
Elements: s.elements.map((e) => e.selector).join(", ") || "(unknown)",
}));
console.table(timelineData);
} else {
console.log(" No significant shifts (all < 0.001)");
}
}
// Recommendations
if (rating !== "good") {
console.log("");
console.log("%c💡 Recommendations:", "font-weight: bold; color: #3b82f6;");
console.log(" • Add width/height attributes to images and videos");
console.log(" • Reserve space for ads, embeds, and dynamic content");
console.log(" • Use CSS aspect-ratio for responsive elements");
console.log(" • Avoid inserting content above existing content");
console.log(" • Use transform animations instead of layout-triggering properties");
}
console.groupEnd();
const topElementsData = getTopElements();
return {
script: "Layout-Shift-Loading-and-Interaction",
status: "ok",
metric: "CLS",
value: Math.round(totalCLS * 10000) / 10000,
unit: "score",
rating,
thresholds: { good: 0.1, needsImprovement: 0.25 },
details: {
currentCLS: Math.round(totalCLS * 10000) / 10000,
shiftCount: allShifts.length,
countedShifts: countedShifts.length,
excludedShifts: excludedShifts.length,
topElements: topElementsData,
},
};
};
// Buffered entries reach the observer callback asynchronously; wait for them so the
// summary below (and the return value) reflect shifts that happened before this ran.
await new Promise((resolve) => setTimeout(resolve, 100));
// Initial message
const rating = valueToRating(totalCLS);
const icon = RATING_ICONS[rating];
console.log("%c📐 Layout Shift Tracking Active", "font-weight: bold; font-size: 14px;");
console.log(` Current CLS: ${icon} ${totalCLS.toFixed(4)}`);
console.log(" Interact with the page to see new shifts.");
console.log(
" Call %cgetLayoutShiftSummary()%c for full analysis.",
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
""
);
// Return for agent (shifts collected by the buffered observer above)
const clsBufferedSync = totalCLS;
const countedSync = allShifts.filter((s) => s.countedForCLS).length;
const excludedSync = allShifts.length - countedSync;
const clsRatingSync = valueToRating(clsBufferedSync);
return {
script: "Layout-Shift-Loading-and-Interaction",
status: "tracking",
metric: "CLS",
value: Math.round(clsBufferedSync * 10000) / 10000,
unit: "score",
rating: clsRatingSync,
thresholds: { good: 0.1, needsImprovement: 0.25 },
details: {
currentCLS: Math.round(clsBufferedSync * 10000) / 10000,
shiftCount: countedSync + excludedSync,
countedShifts: countedSync,
excludedShifts: excludedSync,
topElements: getTopElements(),
},
message: "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
getDataFn: "getLayoutShiftSummary",
};
})();
Understanding the results
Real-time output
Each significant layout shift (> 0.001) logs:
- Shift value with the current CLS (largest session window) and rating
- Whether it counts toward CLS (excluded if user input within 500ms)
- Elements that shifted with their movement details
- Clickable element references for inspection
Summary function
Call getLayoutShiftSummary() in the console to see:
| Section | Description |
|---|---|
| CLS Score | Final score (largest session window) with rating |
| Statistics | Total shifts, counted vs excluded |
| Top Shifting Elements | Elements causing the most shift, sorted by impact |
| Shift Timeline | When shifts occurred and what caused them |
| Recommendations | Specific fixes based on your CLS score |
Common fixes
| Problem | Detection | Solution |
|---|---|---|
| Images without dimensions | <img> in shifting elements | Add width="X" height="Y" attributes |
| Fonts causing reflow | Text elements shifting | Use font-display: optional or size-adjust |
| Dynamic content | Elements inserted at top | Reserve space or insert below fold |
| Ads/embeds | Third-party containers shifting | Use min-height to reserve space |
Browser support
The snippet needs these features to run.
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| LayoutShift (opens in a new tab) | 77 | 79 | ||
| LayoutShift.hadRecentInput (opens in a new tab) | 77 | 79 | ||
| PerformanceObserver (opens in a new tab) | 52 | 79 | 57 | 11 |
| All of the above | 77 | 79 |
The snippet reports on these features. It runs without them, and its advice on each one applies where it is supported.
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| LayoutShift.sources (opens in a new tab) | 84 | 84 |
Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.