# Cumulative layout shift (CLS)

> Source: <https://webperf-snippets.net/CoreWebVitals/CLS>

Quick check for [Cumulative Layout Shift](https://web.dev/articles/cls), 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

| Rating               | Score  | Meaning                        |
| -------------------- | ------ | ------------------------------ |
| 🟢 Good              | ≤ 0.1  | Stable, minimal shifting       |
| 🟡 Needs Improvement | ≤ 0.25 | Noticeable shifting            |
| 🔴 Poor              | > 0.25 | Significant layout instability |

> **Note:** **Need to debug?** Use [Layout Shift Tracking](https://webperf-snippets.net/Interaction/Layout-Shift-Loading-and-Interaction.md) to identify which elements are causing shifts.

### Snippet

```js
// 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

Interactive demo: [Timeline of layout shifts grouped into session windows, comparing the sum of all shifts with the CLS value for three scenarios: one burst, two bursts and a long burst cut at 5 seconds](https://webperf-snippets.net/demos/cls-shifts.html). Step through the shifts to see when a session window opens and closes, and why CLS reports only the largest window.

### What's excluded

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

### CLS calculation flow

Diagram: 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 window

### CLS lifecycle

Diagram: CLS lifecycle: navigation start, page load, accumulating shifts while the page is visible, final value when the page is hidden

### Common causes

| Cause                     | Solution                                      |
| ------------------------- | --------------------------------------------- |
| Images without dimensions | Add `width` and `height` attributes           |
| Ads/embeds without space  | Reserve space with CSS                        |
| Dynamic content injection | Reserve space or insert below fold            |
| Web fonts (FOIT/FOUT)     | Use `font-display: optional` or `size-adjust` |

### Browser support

| Feature                                                                               | Chrome | Edge | Firefox | Safari |
| ------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [LayoutShift](https://developer.mozilla.org/docs/Web/API/LayoutShift)                 |  ✅ 77  | ✅ 79 |    ❌    |    ❌   |
| [PerformanceObserver](https://developer.mozilla.org/docs/Web/API/PerformanceObserver) |  ✅ 52  | ✅ 79 |   ✅ 57  |  ✅ 11  |
| All of the above                                                                      |  ✅ 77  | ✅ 79 |    ❌    |    ❌   |

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
- [Layout Shift Tracking](https://webperf-snippets.net/Interaction/Layout-Shift-Loading-and-Interaction.md) | Detailed debugging snippet
