Skip to content

Instantly share code, notes, and snippets.

@0m3r
Created September 22, 2025 12:37
Show Gist options
  • Save 0m3r/0df25697af5b7a569834e5958e107fc1 to your computer and use it in GitHub Desktop.
Save 0m3r/0df25697af5b7a569834e5958e107fc1 to your computer and use it in GitHub Desktop.
Find CLS
// ===== CLS DIAGNOSTIC SCRIPT WITH SOLUTIONS =====
const trackedProps = [
"width", "height", "top", "left", "right", "bottom",
"margin", "padding", "font-size", "line-height",
"transform", "display", "position", "float",
"overflow", "overflow-x", "overflow-y",
"visibility", "z-index", "flex", "grid"
];
const ignoredProps = ["outline", "box-shadow", "border-color", "color", "background"];
const weight = { style: 3, class: 2, hidden: 1, src: 4, href: 2 };
const impactfulAttributes = Object.keys(weight);
const lastStyles = new WeakMap();
const diffHistory = new WeakMap();
const elementMetadata = new WeakMap();
const EPSILON_PX = 0.5;
// ===== ПРИЧИНИ CLS ТА РІШЕННЯ =====
const CLSCauses = {
IMAGE_WITHOUT_DIMENSIONS: {
name: "Зображення без розмірів",
detect: (element, diffs) => {
return element.tagName === 'IMG' &&
(!element.width || !element.height) &&
diffs.some(d => d.includes('width') || d.includes('height'));
},
solution: "Додайте width і height атрибути або CSS розміри:\n<img src='...' width='300' height='200'>\nабо img { aspect-ratio: 3/2; width: 100%; }"
},
FONT_LOADING: {
name: "Завантаження шрифтів",
detect: (element, diffs) => {
return diffs.some(d => d.includes('font-size') || d.includes('line-height')) &&
document.fonts && document.fonts.status !== 'loaded';
},
solution: "Використайте font-display: swap і preload шрифти:\n<link rel='preload' href='font.woff2' as='font' type='font/woff2' crossorigin>\nfont-family: 'MyFont'; font-display: swap;"
},
DYNAMIC_CONTENT: {
name: "Динамічний контент без резерву місця",
detect: (element, diffs) => {
const hasHeightChange = diffs.some(d => d.includes('height'));
const isContentContainer = element.children.length > 0 || element.textContent.length > 50;
return hasHeightChange && isContentContainer;
},
solution: "Резервуйте місце для динамічного контенту:\n.skeleton { min-height: 200px; }\nабо використайте placeholder з фіксованою висотою"
},
IFRAME_EMBED: {
name: "iframe без розмірів",
detect: (element, diffs) => {
return element.tagName === 'IFRAME' &&
diffs.some(d => d.includes('width') || d.includes('height'));
},
solution: "Встановіть розміри для iframe:\n<iframe width='560' height='315'></iframe>\nабо .iframe-container { aspect-ratio: 16/9; }"
},
FLEX_GRID_CHANGES: {
name: "Зміни в Flexbox/Grid макеті",
detect: (element, diffs) => {
const style = getComputedStyle(element);
return (style.display.includes('flex') || style.display.includes('grid')) &&
diffs.some(d => d.includes('width') || d.includes('height') || d.includes('flex'));
},
solution: "Використайте фіксовані розміри в flex/grid:\n.flex-item { flex: 0 0 200px; }\n.grid-item { grid-template-rows: 200px; }"
},
POSITION_CHANGES: {
name: "Зміни позиціонування",
detect: (element, diffs) => {
return diffs.some(d => d.includes('position') || d.includes('top') ||
d.includes('left') || d.includes('transform'));
},
solution: "Використайте transform замість top/left для анімацій:\ntransform: translateX(100px); /* замість left: 100px */"
},
VISIBILITY_TOGGLE: {
name: "Переключення видимості елементів",
detect: (element, diffs) => {
return diffs.some(d => d.includes('display') || d.includes('visibility')) &&
!element.hasAttribute('data-cls-safe');
},
solution: "Використайте opacity замість display для плавних переходів:\nopacity: 0; pointer-events: none; /* замість display: none */"
},
NAVIGATION_MENU_SHIFTS: {
name: "Зміщення навігаційного меню",
detect: (element, diffs) => {
const isNavElement = element.classList.contains('nav-item') ||
element.classList.contains('menu-item') ||
element.classList.contains('ui-menu-item') ||
element.closest('nav, .navigation, .menu');
const hasLayoutChange = diffs.some(d => d.includes('width') || d.includes('height') || d.includes('margin') || d.includes('padding'));
return isNavElement && hasLayoutChange;
},
solution: "Стабілізуйте навігаційне меню:\n.nav-item { min-height: 40px; }\n.menu { contain: layout; }\nабо використайте CSS Grid з фіксованими розмірами"
},
FLEX_LAYOUT_REFLOW: {
name: "Переобчислення Flexbox макету",
detect: (element, diffs) => {
const parent = element.parentElement;
const isFlexChild = parent && (getComputedStyle(parent).display.includes('flex') ||
element.classList.toString().includes('flex'));
const hasFlexChange = diffs.some(d => d.includes('width') || d.includes('height') || d.includes('flex'));
return isFlexChild && hasFlexChange;
},
solution: "Зафіксуйте розміри flex-елементів:\n.flex-item { flex: 0 0 auto; min-width: fit-content; }\nабо .flex-container { contain: layout; }"
},
LATE_CSS_LOADING: {
name: "Пізнє завантаження CSS або зміна стилів",
detect: (element, diffs) => {
// Перевіряємо чи є зміни що можуть вказувати на пізнє завантаження стилів
const hasStyleChange = diffs.length > 0 && !element.__initialStylesSet;
const isSmallShift = true; // Для малих зсувів часто причина в CSS
return hasStyleChange && isSmallShift;
},
solution: "Оптимізуйте завантаження CSS:\n- Використайте critical CSS inline\n- Додайте preload для CSS: <link rel='preload' href='styles.css' as='style'>\n- Мінімізуйте CSS та використайте HTTP/2"
},
HOVER_STATE_CHANGES: {
name: "Зміни при hover/focus станах",
detect: (element, diffs) => {
return element.matches(':hover, :focus, :active') &&
diffs.some(d => d.includes('padding') || d.includes('margin') || d.includes('width') || d.includes('height'));
},
solution: "Стабілізуйте hover стани:\n.item:hover { transform: scale(1.05); /* замість padding/margin */ }\nабо зарезервуйте місце: .item { padding: 10px; transition: all 0.3s; }"
}
};
// ===== UTILITY FUNCTIONS =====
function parsePx(value) {
if (!value) return 0;
const num = parseFloat(value);
return isNaN(num) ? 0 : num;
}
function isSignificantChange(prop, before, after) {
if (before.endsWith && before.endsWith("px") && after.endsWith && after.endsWith("px")) {
return Math.abs(parsePx(before) - parsePx(after)) > EPSILON_PX;
}
return before !== after;
}
function getElementSelector(element) {
if (element.id) return `#${element.id}`;
if (element.className) return `.${Array.from(element.classList).join('.')}`;
return element.tagName.toLowerCase();
}
function analyzeCause(element, diffs) {
const causes = [];
for (const [key, cause] of Object.entries(CLSCauses)) {
if (cause.detect(element, diffs)) {
causes.push(cause);
}
}
// Додатковий аналіз для випадків коли не знайдено конкретної причини
if (causes.length === 0) {
causes.push(analyzeGenericCause(element, diffs));
}
return causes;
}
function analyzeGenericCause(element, diffs) {
const elementInfo = {
tagName: element.tagName,
classes: Array.from(element.classList),
isVisible: element.offsetParent !== null,
hasChildren: element.children.length > 0,
isInViewport: isElementInViewport(element)
};
// Аналізуємо специфічні паттерни для вашого випадку
if (elementInfo.classes.some(cls => cls.includes('nav') || cls.includes('menu'))) {
return {
name: "Можлива причина: Ініціалізація навігаційного меню",
solution: `Рекомендації для навігації:
1. Додайте CSS contain: layout для меню контейнера
2. Встановіть min-height для пунктів меню
3. Перевірте JavaScript ініціалізацію меню
4. Можливо меню завантажується асинхронно
CSS рішення:
.nav-container { contain: layout; }
.menu-item { min-height: 40px; }
.ui-menu-item { transition: none; }`
};
}
if (elementInfo.classes.some(cls => cls.includes('flex'))) {
return {
name: "Можлива причина: Переобчислення Flexbox макету",
solution: `Рекомендації для Flex контейнерів:
1. Використайте фіксовані розміри замість auto
2. Додайте CSS contain для ізоляції макету
3. Перевірте чи не змінюються дочірні елементи динамічно
CSS рішення:
.flex-col-right { contain: layout; flex: 0 0 auto; }
.flex-container { min-height: 100px; }`
};
}
return {
name: "Загальна діагностика малих зсувів",
solution: `Можливі причини малих layout shifts:
1. 📡 Асинхронне завантаження JavaScript/CSS
2. 🎨 Зміна стилів після ініціалізації
3. 📱 Адаптивний дизайн (media queries)
4. 🔧 Сторонні віджети або скрипти
5. ⚡ Web fonts завантаження
Загальні рішення:
- Додайте contain: layout для стабільних контейнерів
- Встановіть explicit розміри де можливо
- Використайте CSS Grid замість Flexbox для стабільніших макетів
- Перевірте DevTools > Performance для точного визначення причин`
};
}
function isElementInViewport(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
function generateReport(element, diffs, causes, shiftValue) {
const selector = getElementSelector(element);
console.group(`🔍 CLS Analysis Report - ${selector}`);
console.log(`📊 Shift Impact: ${shiftValue.toFixed(5)}`);
console.log(`🎯 Element:`, element);
if (causes.length > 0) {
console.group("🚨 Identified Causes:");
causes.forEach((cause, index) => {
console.group(`${index + 1}. ${cause.name}`);
console.log("💡 Solution:");
console.log(cause.solution);
console.groupEnd();
});
console.groupEnd();
} else {
console.log("❓ No specific cause identified. Check for:");
console.log("- Late-loading resources");
console.log("- JavaScript-triggered style changes");
console.log("- Third-party widgets");
}
if (diffs.length > 0) {
console.group("📋 Style Changes Detected:");
diffs.forEach(diff => console.log(diff));
console.groupEnd();
}
console.groupEnd();
}
// ===== CORE FUNCTIONALITY =====
function diffStyles(node, newStyles) {
const oldStyles = lastStyles.get(node) || {};
const diffs = [];
trackedProps.forEach(prop => {
if (ignoredProps.includes(prop)) return;
const oldVal = oldStyles[prop] || "";
const newVal = newStyles[prop] || "";
if (isSignificantChange(prop, oldVal, newVal)) {
diffs.push(`${prop}: "${oldVal}" → "${newVal}"`);
}
});
if (diffs.length) {
if (!diffHistory.has(node)) diffHistory.set(node, []);
diffHistory.get(node).push(...diffs);
}
return diffs;
}
function observeNode(node) {
if (!(node instanceof Element)) return;
if (node.__clsObserved) return;
node.__clsObserved = true;
// Зберігаємо метадані елемента
elementMetadata.set(node, {
tagName: node.tagName,
initialStyles: {},
createdAt: Date.now()
});
const snapshot = {};
const computed = getComputedStyle(node);
trackedProps.forEach(p => snapshot[p] = computed[p]);
lastStyles.set(node, snapshot);
new MutationObserver((mutations) => {
if (node.__clsHighlighting) return;
const filtered = mutations
.filter(m => m.type === "attributes" && impactfulAttributes.includes(m.attributeName))
.map(m => ({
name: m.attributeName,
value: node.getAttribute(m.attributeName)
}));
if (!filtered.length) return;
filtered.sort((a, b) => (weight[a.name] || 0) - (weight[b.name] || 0));
const newComputed = getComputedStyle(node);
const diffs = diffStyles(node, newComputed);
if (!diffs.length) return;
// Аналізуємо причини CLS
const causes = analyzeCause(node, diffs);
generateReport(node, diffs, causes, 0); // shiftValue буде доступний в PerformanceObserver
const snapshotNew = {};
trackedProps.forEach(p => snapshotNew[p] = newComputed[p]);
lastStyles.set(node, snapshotNew);
}).observe(node, { attributes: true, attributeFilter: impactfulAttributes });
}
function highlightNode(node, shiftValue) {
if (node.__clsHighlighting) return;
node.__clsHighlighting = true;
const prevOutline = node.style.outline;
const intensity = Math.min(shiftValue * 1000, 1); // Нормалізуємо до 0-1
const color = intensity > 0.1 ? 'red' : intensity > 0.05 ? 'orange' : 'yellow';
node.style.outline = `3px solid ${color}`;
node.style.outlineOffset = '2px';
// Додаємо тултіп з інформацією
const tooltip = document.createElement('div');
tooltip.style.cssText = `
position: absolute;
background: rgba(0,0,0,0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 10000;
pointer-events: none;
`;
tooltip.textContent = `CLS: ${shiftValue.toFixed(5)}`;
const rect = node.getBoundingClientRect();
tooltip.style.left = rect.left + 'px';
tooltip.style.top = (rect.top - 30) + 'px';
document.body.appendChild(tooltip);
setTimeout(() => {
node.style.outline = prevOutline || "";
node.style.outlineOffset = "";
document.body.removeChild(tooltip);
node.__clsHighlighting = false;
}, 3000);
}
// ===== PERFORMANCE OBSERVER =====
let totalCLS = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
totalCLS += entry.value;
console.group(`📐 Layout Shift Detected (Score: ${entry.value.toFixed(5)}, Total CLS: ${totalCLS.toFixed(5)})`);
entry.sources.forEach(({ node }) => {
if (!(node instanceof Element)) return;
observeNode(node);
highlightNode(node, entry.value);
// Отримуємо поточні стилі для аналізу
const currentStyles = {};
const computed = getComputedStyle(node);
trackedProps.forEach(p => currentStyles[p] = computed[p]);
const diffs = diffStyles(node, currentStyles);
const causes = analyzeCause(node, diffs);
generateReport(node, diffs, causes, entry.value);
});
console.groupEnd();
// Попередження при високому CLS
if (totalCLS > 0.25) {
console.warn(`⚠️ HIGH CLS WARNING: Total CLS (${totalCLS.toFixed(5)}) exceeds Google's "Poor" threshold (0.25)`);
} else if (totalCLS > 0.1) {
console.warn(`⚠️ CLS WARNING: Total CLS (${totalCLS.toFixed(5)}) exceeds Google's "Good" threshold (0.1)`);
}
}
}).observe({ type: "layout-shift", buffered: true });
// ===== ДОДАТКОВІ УТИЛІТИ =====
// Функція для детального аналізу конкретного елемента
window.deepAnalyzeCLS = function(element) {
if (typeof element === 'string') {
element = document.querySelector(element);
}
if (!element) {
console.error('Element not found');
return;
}
console.group(`🔬 Deep CLS Analysis for ${getElementSelector(element)}`);
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
console.log('📐 Element dimensions:', {
width: rect.width,
height: rect.height,
top: rect.top,
left: rect.left
});
console.log('🎨 Key styles:', {
display: style.display,
position: style.position,
flex: style.flex,
width: style.width,
height: style.height,
contain: style.contain
});
// Перевіряємо батьківські елементи
let parent = element.parentElement;
let level = 0;
console.group('👨‍👩‍👧‍👦 Parent hierarchy:');
while (parent && level < 3) {
const parentStyle = getComputedStyle(parent);
console.log(`Level ${level}: ${getElementSelector(parent)} - display: ${parentStyle.display}, contain: ${parentStyle.contain}`);
parent = parent.parentElement;
level++;
}
console.groupEnd();
// Рекомендації
console.group('💡 Specific recommendations:');
if (element.classList.contains('ui-menu-item')) {
console.log('🍔 Menu item detected:');
console.log('- Add: .ui-menu-item { min-height: 40px; contain: layout; }');
console.log('- Consider: transition: none to prevent smooth animations causing shifts');
}
if (element.classList.toString().includes('flex')) {
console.log('📏 Flex element detected:');
console.log('- Add: flex: 0 0 auto or specific width');
console.log('- Consider: contain: layout for flex container');
}
if (rect.height < 50) {
console.log('📏 Small element detected:');
console.log('- Consider adding min-height to prevent collapse');
console.log('- Check if content is loading asynchronously');
}
console.groupEnd();
console.groupEnd();
// Запускаємо спостереження
observeNode(element);
return {
element,
recommendations: 'See console for detailed recommendations'
};
};
// Функція для моніторингу всіх навігаційних елементів
window.monitorNavigation = function() {
const navElements = document.querySelectorAll(`
[class*="nav-"], [class*="menu-"], [class*="ui-menu"],
nav *, .navigation *, .menu *
`);
console.log(`🍔 Found ${navElements.length} navigation elements to monitor`);
navElements.forEach((el, index) => {
observeNode(el);
if (index < 5) { // Показуємо тільки перші 5
console.log(`${index + 1}. ${getElementSelector(el)}`);
}
});
return navElements;
};
// Функція для отримання звіту про CLS
window.getCLSReport = function() {
console.group('📊 CLS Summary Report');
console.log(`Total CLS Score: ${totalCLS.toFixed(5)}`);
const recommendation = totalCLS <= 0.1 ? 'Good 👍' :
totalCLS <= 0.25 ? 'Needs Improvement ⚠️' :
'Poor 🚨';
console.log(`Google PageSpeed Rating: ${recommendation}`);
console.log('\n🎯 Quick Tips to Reduce CLS:');
console.log('1. Set explicit dimensions for images and iframes');
console.log('2. Use font-display: swap for web fonts');
console.log('3. Reserve space for dynamic content');
console.log('4. Avoid inserting content above existing content');
console.log('5. Use transform instead of changing layout properties');
console.groupEnd();
};
// Автоматичний звіт через 10 секунд
setTimeout(() => {
window.getCLSReport();
}, 10000);
console.log(`🚀 Enhanced CLS Diagnostic Script loaded!
Available commands:
📊 getCLSReport() - General CLS summary
🔍 analyzeCLS("selector") - Start monitoring specific element
🔬 deepAnalyzeCLS("selector") - Deep analysis of element
🍔 monitorNavigation() - Monitor all navigation elements
Based on your output, try:
🎯 deepAnalyzeCLS(".ui-menu-item")
🎯 monitorNavigation()
`);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment