Interaction
Layout Shift Loading and Interaction

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

RatingCLS ScoreMeaning
Good≤ 0.1Minimal, stable layout
Needs Improvement≤ 0.25Noticeable shifting
Poor> 0.25Significant layout instability

What causes layout shifts

CauseExampleSolution
Images without dimensions<img src="...">Add width and height attributes
Ads/embeds without reserved spaceDynamic ad injectionReserve space with CSS
Dynamically injected contentBanners, notificationsReserve space or use transforms
Web fonts causing FOIT/FOUTFont swap changes text sizeUse font-display: optional or size-adjust
DOM updates above viewportContent inserted above current scrollInsert 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

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 againConnections: Page Load to Loading Phase; Loading Phase to User Interaction; User Interaction to 500ms Window; 500ms Window to After Window.PageLoadLoading PhaseInitial layout shiftsCount toward CLSUserInteraction500ms WindowShifts excludedAfter WindowCount toward CLS
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 againConnections: Page Load to Loading Phase; Loading Phase to User Interaction; User Interaction to 500ms Window; 500ms Window to After Window.PageLoadLoading PhaseInitial layout shiftsCount toward CLSUserInteraction500ms WindowShifts excludedAfter WindowCount toward CLS

CLS counting logic

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 itConnections: Layout Shift Detected to hadRecentInput? User input within 500ms?; hadRecentInput? User input within 500ms? to During loading or interaction? (false); hadRecentInput? User input within 500ms? to Exclude from CLS Shift within interaction window (true); During loading or interaction? to Count toward CLS Post-load shift (After loading); During loading or interaction? to Count toward CLS Loading phase shift (Loading).Layout Shift DetectedhadRecentInput?User input within 500ms?Exclude from CLSShift within interaction windowDuring loadingor interaction?Count toward CLSLoading phase shiftCount toward CLSPost-load shiftfalsetrueAfter loadingLoading
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 itConnections: Layout Shift Detected to hadRecentInput? User input within 500ms?; hadRecentInput? User input within 500ms? to During loading or interaction? (false); hadRecentInput? User input within 500ms? to Exclude from CLS Shift within interaction window (true); During loading or interaction? to Count toward CLS Post-load shift (After loading); During loading or interaction? to Count toward CLS Loading phase shift (Loading).Layout ShiftDetectedhadRecentInput?User inputwithin 500ms?Exclude fromCLSShift withininteractionwindowDuring loadingor interaction?Count towardCLSLoading phaseshiftCount towardCLSPost-load shiftfalsetrueAfter loadingLoading

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:

SectionDescription
CLS ScoreFinal score (largest session window) with rating
StatisticsTotal shifts, counted vs excluded
Top Shifting ElementsElements causing the most shift, sorted by impact
Shift TimelineWhen shifts occurred and what caused them
RecommendationsSpecific fixes based on your CLS score

Common fixes

ProblemDetectionSolution
Images without dimensions<img> in shifting elementsAdd width="X" height="Y" attributes
Fonts causing reflowText elements shiftingUse font-display: optional or size-adjust
Dynamic contentElements inserted at topReserve space or insert below fold
Ads/embedsThird-party containers shiftingUse min-height to reserve space

Browser support

The snippet needs these features to run.

FeatureChromeEdgeFirefoxSafari
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.

FeatureChromeEdgeFirefoxSafari
LayoutShift.sources (opens in a new tab) 84 84

Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.

Further reading