Skip to Content
Core Web VitalsCLS

Cumulative layout shift (CLS)

Chrome 77+ Edge 79+ Firefox Safari Details

Quick check for Cumulative Layout Shift (opens in a new tab), a Core Web Vital that measures visual stability. CLS tracks how much the page layout shifts unexpectedly during its lifetime, providing a single score that represents the largest burst of unexpected layout shifts.

Why this matters

Unexpected layout shifts are frustrating and can cause users to click the wrong element or lose their reading position. CLS directly impacts user experience and is a ranking factor for Google Search. Common causes include images without dimensions, web fonts, and dynamically injected content.

CLS rating thresholds

RatingScoreMeaning
Good≤ 0.1Stable, minimal shifting
Needs Improvement≤ 0.25Noticeable shifting
Poor> 0.25Significant layout instability

Need to debug? Use Layout Shift Tracking to identify which elements are causing shifts.

Snippet

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// CLS Quick Check
// https://webperf-snippets.net

(async () => {
  if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
    console.warn("⚠️ layout-shift entries are not supported in this browser.");
    return { script: "CLS", status: "unsupported", error: "layout-shift entries not supported in this browser" };
  }
  // CLS is the largest session window: shifts less than 1s apart, in a window of at most 5s.
  let cls = 0;
  let sessionValue = 0;
  let sessionFirst = null;
  let sessionLast = null;

  const addShift = (entry) => {
    if (entry.hadRecentInput) return;
    if (
      sessionLast &&
      entry.startTime - sessionLast.startTime < 1000 &&
      entry.startTime - sessionFirst.startTime < 5000
    ) {
      sessionValue += entry.value;
    } else {
      sessionValue = entry.value;
      sessionFirst = entry;
    }
    sessionLast = entry;
    cls = Math.max(cls, sessionValue);
  };

  const valueToRating = (score) =>
    score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";

  const RATING = {
    good: { icon: "🟢", color: "#0CCE6A" },
    "needs-improvement": { icon: "🟡", color: "#FFA400" },
    poor: { icon: "🔴", color: "#FF4E42" },
  };

  const logCLS = () => {
    const rating = valueToRating(cls);
    const { icon, color } = RATING[rating];
    console.log(
      `%cCLS: ${icon} ${cls.toFixed(4)} (${rating})`,
      `color: ${color}; font-weight: bold; font-size: 14px;`
    );
  };

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) addShift(entry);
    logCLS();
  });

  observer.observe({ type: "layout-shift", buffered: true });

  // Update on visibility change (final CLS)
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") {
      observer.takeRecords();
      console.log("%c📊 Final CLS (on page hide):", "font-weight: bold;");
      logCLS();
    }
  });

  // Expose function for manual check
  window.getCLS = () => {
    logCLS();
    const rating = valueToRating(cls);
    return {
      script: "CLS",
      status: "ok",
      metric: "CLS",
      value: Math.round(cls * 10000) / 10000,
      unit: "score",
      rating,
      thresholds: { good: 0.1, needsImprovement: 0.25 },
    };
  };

  console.log(
    "   Call %cgetCLS()%c anytime to check current value.",
    "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
    ""
  );

  // Return for agent. Chrome only exposes layout-shift entries through a PerformanceObserver,
  // so wait for the buffered observer above to deliver them.
  await new Promise((resolve) => setTimeout(resolve, 100));
  const clsSync = cls;
  const clsRating = valueToRating(clsSync);
  return {
    script: "CLS",
    status: "ok",
    metric: "CLS",
    value: Math.round(clsSync * 10000) / 10000,
    unit: "score",
    rating: clsRating,
    thresholds: { good: 0.1, needsImprovement: 0.25 },
    message: "CLS tracking active. Call getCLS() for updated value after page interactions.",
    getDataFn: "getCLS",
  };
})();

Understanding CLS

CLS groups unexpected layout shifts into session windows and reports the largest window. A window starts at a shift and continues while each new shift arrives less than 1 second after the previous one, up to 5 seconds in total. The score of a window is the sum of its shift scores:

shift score = impact fraction × distance fraction CLS = max over session windows of Σ shift scores
  • Impact fraction: How much of the viewport was affected
  • Distance fraction: How far elements moved
Step through the shifts to see when a session window opens and closes, and why CLS reports only the largest window. Open demo in a new tab ↗

What’s excluded

  • Shifts within 500ms of user input (hadRecentInput: true)
  • Shifts caused by scroll anchoring

CLS calculation flow

How a layout shift is scored: shifts within 500 ms of user input are excluded, the others multiply impact fraction by distance fraction and add to the session windowConnections: Layout Shift Detected to Within 500ms of user input?; Within 500ms of user input? to Exclude from CLS hadRecentInput: true (Yes); Within 500ms of user input? to Include in CLS (No); Include in CLS to Calculate Impact Fraction % of viewport affected; Calculate Impact Fraction % of viewport affected to Calculate Distance Fraction distance moved / viewport; Calculate Distance Fraction distance moved / viewport to Shift Score = Impact × Distance; Shift Score = Impact × Distance to Add to the current session window CLS = largest window.Layout Shift DetectedWithin 500msof user input?Exclude from CLShadRecentInput: trueInclude in CLSCalculate Impact Fraction% of viewport affectedCalculate Distance Fractiondistance moved / viewportShift Score = Impact × DistanceAdd to the current sessionwindowCLS = largest windowYesNo

CLS lifecycle

CLS lifecycle: navigation start, page load, accumulating shifts while the page is visible, final value when the page is hiddenConnections: start to Page Load (Navigation Start); Page Load to Accumulating (First Shift); Accumulating to Accumulating (More Shifts (window += score, CLS = largest window)); Accumulating to Final (Page Hidden); Final to end.Page LoadAccumulatingFinalOnly shifts wherehadRecentInput = falseare countedNavigation StartFirst ShiftMore Shifts(window += score, CLS =largest window)Page Hidden
CLS lifecycle: navigation start, page load, accumulating shifts while the page is visible, final value when the page is hiddenConnections: start to Page Load (Navigation Start); Page Load to Accumulating (First Shift); Accumulating to Accumulating (More Shifts (window += score, CLS = largest window)); Accumulating to Final (Page Hidden); Final to end.Page LoadAccumulatingFinalOnly shifts wherehadRecentInput =falseare countedNavigation StartFirst ShiftMore Shifts(window += score,CLS = largestwindow)Page Hidden

Common causes

CauseSolution
Images without dimensionsAdd width and height attributes
Ads/embeds without spaceReserve space with CSS
Dynamic content injectionReserve space or insert below fold
Web fonts (FOIT/FOUT)Use font-display: optional or size-adjust

Browser support

FeatureChromeEdgeFirefoxSafari
LayoutShift (opens in a new tab) 77 79
PerformanceObserver (opens in a new tab) 52 79 57 11
All of the above 77 79

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

Further reading

Last updated on
MIT 2026 © Joan León | @nucliweb