# Layout shift tracking

> Source: <https://webperf-snippets.net/Interaction/Layout-Shift-Loading-and-Interaction>

Tracks all layout shifts during page load and user interaction, identifying the elements causing [Cumulative Layout Shift (CLS)](https://web.dev/articles/cls). This debugging-focused snippet logs every shift with detailed information about which elements moved and when.

### Why this matters

While the [CLS quick check](https://webperf-snippets.net/CoreWebVitals/CLS.md) 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

Diagram: Phases of layout shifts: shifts during page load count toward CLS, shifts in the 500 ms window after a user interaction are excluded, and shifts after that window count again

### CLS counting logic

Diagram: Decision flow for counting a layout shift: shifts with recent user input are excluded from CLS, the others count whether they happen during loading or after it

### Snippet

```js
// Layout Shift Tracking
// https://webperf-snippets.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](https://developer.mozilla.org/docs/Web/API/LayoutShift)                               |  ✅ 77  | ✅ 79 |    ❌    |    ❌   |
| [LayoutShift.hadRecentInput](https://developer.mozilla.org/docs/Web/API/LayoutShift/hadRecentInput) |  ✅ 77  | ✅ 79 |    ❌    |    ❌   |
| [PerformanceObserver](https://developer.mozilla.org/docs/Web/API/PerformanceObserver)               |  ✅ 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](https://developer.mozilla.org/docs/Web/API/LayoutShift/sources) |  ✅ 84  | ✅ 84 |    ❌    |    ❌   |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Cumulative Layout Shift (CLS)](https://web.dev/articles/cls) | web.dev
- [Optimize CLS](https://web.dev/articles/optimize-cls) | web.dev
- [Debug layout shifts](https://web.dev/articles/debug-layout-shifts) | web.dev
- [Layout Instability API](https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift) | MDN
