A complete developer tutorial on measuring, diagnosing, and eliminating Cumulative Layout Shift in React applications using the open-source @page-speed/hooks library.
What Is Cumulative Layout Shift — And Why Should You Care?
You've been there. You land on a webpage, spot a link you want to click, move your finger toward it — and suddenly the page shifts. You tap a completely different element. Maybe you accidentally clicked an ad, submitted a form early, or worse, hit "Buy Now" on the wrong item.
That frustrating experience has a name: Cumulative Layout Shift (CLS). It's a Core Web Vital defined by Google that measures the visual stability of your page by quantifying how much content unexpectedly moves during the lifetime of a page visit.
Where:
- Impact Fraction is the fraction of the viewport affected by the shift
- Distance Fraction is the maximum distance any element moved divided by the viewport's largest dimension
The CLS metric is not the sum of all layout shifts — it's the maximum score of any single session window: a burst of layout shifts with less than 1 second between individual shifts and a maximum duration of 5 seconds. This 2021 redesign of CLS was introduced specifically to be fairer to long-lived pages and SPAs.
The Thresholds That Matter
| Rating | CLS Score |
|---|---|
| ✅ Good | ≤ 0.1 |
| ⚠️ Needs Improvement | 0.1 – 0.25 |
| ❌ Poor | > 0.25 |
Google requires that at least 75% of page visits score "good" before a site can be considered performant. And this matters — poor CLS directly affects SEO rankings, drives up bounce rates, destroys conversion rates, and creates terrible experiences for users on slow connections or mobile devices.
Why React Apps Are CLS Hotspots
React's component-based, JavaScript-driven rendering model creates several natural CLS risk vectors that server-rendered HTML does not have:
- Lazy-loaded components that pop in below static content after hydration
-
Dynamic data fetching via
useEffectthat inserts content above-the-fold after mount -
Unsized images in component libraries where
widthandheightare not enforced by the component contract - Web fonts loaded via CSS that swap after the initial paint
-
CSS animations using layout-affecting properties like
top,left,width, orheightinstead oftransform - Ad slots and third-party embeds that expand beyond their reserved space
None of these are unique to React, but React's default behavior of rendering a blank shell and then progressively filling it in with JavaScript makes each of these problems worse and harder to debug.
The traditional fix has been to instrument PerformanceObserver manually, interpret raw layout-shift entries, group them into session windows by hand, and build your own attribution logic. That's a lot of boilerplate for every project.
Introducing @page-speed/hooks and useCLS
@page-speed/hooks is an open-source library from OpenSite AI that wraps the web.dev web-vitals library and the raw Performance APIs into ergonomic, zero-config React hooks. The library is tree-shakeable, TypeScript-first, SSR-compatible, and used in production at OpenSite.
The current version is 0.4.6, and the full bundle is only ~12 KB gzipped, with useCLS specifically costing far less when imported individually via tree-shaking.
npm install @page-speed/hooks
# or
pnpm add @page-speed/hooks
# or
yarn add @page-speed/hooks
The library requires react >= 16.8.0 as a peer dependency, which means it works everywhere from legacy React projects to the latest Next.js App Router.
Understanding the useCLS Hook Architecture
Before writing any code, it's worth understanding how useCLS is built internally. This will help you reason about its behavior in production and make better decisions about how to configure it.
Dual Observer Strategy
The hook uses two concurrent measurement strategies:
onCLSfromweb-vitals— The primary measurement path. Google's officialweb-vitalslibrary is invoked inside auseEffectand handles the complex accounting of session windows, page visibility changes, back-forward cache restores, and all the edge cases documented on web.dev. This produces the canonical CLS value.PerformanceObserverforlayout-shift— A seconduseEffectsets up a rawPerformanceObserverthat fires theonShiftcallback in real-time as each individual shift occurs (withbuffered: trueto also capture past shifts). This enables per-shift callbacks for logging, alerting, or real-time dashboards.
Ref-Based State Pattern for Callbacks
One of the most important architectural decisions in the hook is the use of useRef for the options object:
const optionsRef = useRef(options);
optionsRef.current = options;
This pattern solves the classic React stale closure problem. Because onCLS is registered once in a useEffect, a naïve implementation would capture the onMeasure callback at registration time and never update it. By keeping options in a ref and always reading from optionsRef.current, the hook can accept new callback functions on re-renders without ever re-registering the PerformanceObserver — which would cause double-counting. The test suite explicitly validates this:
it("uses the latest onMeasure callback without re-registering", async () => {
// rerender with a new callback...
expect(mockOnCLS).toHaveBeenCalledTimes(1); // Observer registered only once
expect(second).toHaveBeenCalledWith(0.05, "good"); // But latest callback fires
});
Session Window Algorithm
The buildSessionWindows function in useCLS reimplements the official CLS session window algorithm:
const gap = entry.startTime - currentWindow.endTime;
const duration = entry.startTime - currentWindow.startTime;
// Close window if gap > 1s or duration > 5s
if (gap > 1000 || duration > 5000) {
windows.push(currentWindow);
// start new window
} else {
currentWindow.value += entry.value;
currentWindow.entries.push(entry);
}
Shifts with hadRecentInput: true are excluded from windows, since shifts within 500ms of user interaction (clicks, taps, keypresses) are intentional and should not count against the CLS score.
Automatic Issue Detection
When detectIssues: true (the default), the hook analyzes the largest shift's source elements to categorize what caused the shift. It checks for six issue types:
| Issue Type | Detection Logic |
|---|---|
image-without-dimensions |
<img> without explicit width/height attributes or aspect-ratio CSS |
unsized-media |
<video> or <picture> without explicit dimensions |
dynamic-content |
Default fallback for most shifts |
web-font-shift |
Text elements (<span>, <p>) with small vertical shift distances (<20px) |
ad-embed-shift |
<iframe> or elements with [data-ad] attribute |
animation-shift |
Elements where animation, transition, or transform CSS is non-trivial |
For each detected issue, the hook generates a concrete, actionable suggestion string that tells you exactly what to do.
Complete API Reference
Options (CLSOptions)
interface CLSOptions {
threshold?: number; // CLS alert threshold (default: 0.1)
onMeasure?: (value: number, rating: 'good' | 'needs-improvement' | 'poor') => void;
onShift?: (entry: LayoutShiftEntry) => void; // Called per-shift
onIssue?: (issue: CLSIssue) => void; // Called when issue detected
reportAllChanges?: boolean; // Report every update, not just final (default: false)
debug?: boolean; // Console warnings (default: true in dev)
detectIssues?: boolean; // Auto-detect issue types (default: true)
trackAttribution?: boolean; // PerformanceObserver for per-shift callbacks (default: true)
observeRef?: React.RefObject<HTMLElement>; // Scope to a specific container
}
Return Value (CLSState)
interface CLSState {
cls: number | null; // Current CLS score (null until measured)
rating: 'good' | 'needs-improvement' | 'poor' | null;
isLoading: boolean; // True until first measurement
entries: LayoutShiftEntry[]; // All individual layout shifts
largestShift: LayoutShiftEntry | null; // The single biggest shift
sessionWindows: CLSSessionWindow[]; // Grouped shift windows
largestSessionWindow: CLSSessionWindow | null;
issues: CLSIssue[]; // Auto-detected optimization opportunities
shiftCount: number; // Total count of shifts
hasPostInteractionShifts: boolean; // Shifts after user interaction
utils: {
getElementSelector: (element: Element | null) => string | null;
hasExplicitDimensions: (element: HTMLElement | null) => boolean;
getAspectRatio: (width: number, height: number) => { ratio: string; decimal: number };
reset: () => void;
};
}
Tutorial: Building From Zero to Production
Step 1: The Minimum Viable Monitor
The simplest possible usage — just drop this invisible component anywhere in your component tree. It will start measuring CLS immediately and log to your analytics:
// components/PerformanceMonitor.tsx
"use client"; // Required for Next.js App Router
import { useCLS } from "@page-speed/hooks";
export function PerformanceMonitor() {
useCLS({
onMeasure: (value, rating) => {
// Replace with your analytics provider
console.log(`CLS: ${value.toFixed(3)} (${rating})`);
// Send to Google Analytics
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "web_vitals", {
event_category: "CLS",
value: Math.round(value * 1000), // gtag expects integers
event_label: rating,
non_interaction: true,
});
}
},
});
return null; // This component renders nothing
}
Place it in your root layout:
// app/layout.tsx (Next.js App Router)
import { PerformanceMonitor } from "@/components/PerformanceMonitor";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<PerformanceMonitor />
{children}
</body>
</html>
);
}
That's it. You're now capturing real-user CLS data in production.
Step 2: Development Debug Dashboard
During development, you want visibility into what's actually shifting and why. Here's a comprehensive debug component that surfaces everything useCLS knows:
// components/CLSDebugPanel.tsx
"use client";
import { useCLS } from "@page-speed/hooks";
const ratingColors = {
good: "#0cce6b",
"needs-improvement": "#ffa400",
poor: "#ff4e42",
null: "#888",
};
export function CLSDebugPanel() {
const {
cls,
rating,
shiftCount,
largestShift,
sessionWindows,
issues,
hasPostInteractionShifts,
} = useCLS({ reportAllChanges: true });
// Only show in development
if (process.env.NODE_ENV !== "development") return null;
return (
<div
style={{
position: "fixed",
bottom: 16,
right: 16,
width: 320,
background: "#1a1a2e",
color: "#eee",
borderRadius: 8,
padding: 16,
fontFamily: "monospace",
fontSize: 12,
zIndex: 9999,
boxShadow: "0 4px 20px rgba(0,0,0,0.5)",
}}
>
<h3 style={{ margin: "0 0 12px", fontSize: 14 }}>⚡ CLS Monitor</h3>
<div style={{ marginBottom: 12 }}>
<span>Score: </span>
<strong
style={{ color: ratingColors[rating ?? "null"], fontSize: 18 }}
>
{cls?.toFixed(4) ?? "..."}
</strong>
{rating && (
<span
style={{
marginLeft: 8,
color: ratingColors[rating],
textTransform: "uppercase",
fontSize: 10,
}}
>
{rating}
</span>
)}
</div>
<div style={{ marginBottom: 8 }}>
<div>Total Shifts: {shiftCount}</div>
<div>Session Windows: {sessionWindows.length}</div>
{hasPostInteractionShifts && (
<div style={{ color: "#ffa400" }}>⚠ Post-interaction shifts detected</div>
)}
</div>
{largestShift && (
<div style={{ marginBottom: 8, borderTop: "1px solid #333", paddingTop: 8 }}>
<div style={{ color: "#888", marginBottom: 4 }}>Largest Shift</div>
<div>Value: {largestShift.value.toFixed(4)}</div>
<div>Time: {largestShift.startTime.toFixed(0)}ms</div>
{largestShift.sources.length > 0 && (
<div style={{ color: "#aaa", marginTop: 4 }}>
Elements:
{largestShift.sources
.filter((s) => s.node)
.map((s, i) => (
de
key={i}
style={{
display: "block",
background: "#2d2d4e",
padding: "2px 4px",
borderRadius: 3,
marginTop: 2,
}}
>
{s.node}
</code>
))}
</div>
)}
</div>
)}
{issues.length > 0 && (
<div style={{ borderTop: "1px solid #333", paddingTop: 8 }}>
<div style={{ color: "#ff4e42", marginBottom: 4 }}>
🔍 {issues.length} Issue{issues.length > 1 ? "s" : ""} Found
</div>
{issues.map((issue, i) => (
<div key={i} style={{ marginBottom: 8 }}>
<div style={{ color: "#ffa400" }}>{issue.type}</div>
<div style={{ color: "#aaa", fontSize: 11, lineHeight: 1.4 }}>
{issue.suggestion}
</div>
</div>
))}
</div>
)}
</div>
);
}
This panel will show you in real time which elements are shifting, their scores, and exactly what the hook recommends fixing.
Step 3: Fix the Most Common CLS Cause — Images Without Dimensions
The most common React CLS culprit is images rendered without explicit width and height attributes. When the browser doesn't know an image's dimensions before it loads, it allocates zero space for it. When the image loads, it pushes all surrounding content down.
The useCLS hook will detect this as an image-without-dimensions issue and even provide the CSS fix in its suggestion string. But you can also be proactive using the hasExplicitDimensions and getAspectRatio utilities:
// components/ImageAudit.tsx
"use client";
import { useEffect } from "react";
import { useCLS } from "@page-speed/hooks";
export function ImageAudit() {
const { utils } = useCLS({
onIssue: (issue) => {
if (issue.type === "image-without-dimensions") {
console.error(
`%c[CLS] Image missing dimensions: ${issue.element}`,
"color: #ff4e42; font-weight: bold"
);
console.info(`Fix: ${issue.suggestion}`);
}
},
});
useEffect(() => {
// Run a full image audit after mount
const images = Array.from(document.querySelectorAll("img"));
images.forEach((img) => {
if (!utils.hasExplicitDimensions(img)) {
const selector = utils.getElementSelector(img);
// Get the natural dimensions once loaded
if (img.complete) {
const { ratio } = utils.getAspectRatio(
img.naturalWidth,
img.naturalHeight
);
console.warn(
`[CLS Audit] Image needs dimensions: ${selector}\n` +
` Option 1: <img width="${img.naturalWidth}" height="${img.naturalHeight}" />\n` +
` Option 2: CSS aspect-ratio: ${ratio};`
);
} else {
img.addEventListener("load", () => {
const { ratio } = utils.getAspectRatio(
img.naturalWidth,
img.naturalHeight
);
console.warn(
`[CLS Audit] Image needs dimensions: ${selector}\n` +
` Option 1: <img width="${img.naturalWidth}" height="${img.naturalHeight}" />\n` +
` Option 2: CSS aspect-ratio: ${ratio};`
);
});
}
}
});
}, [utils]);
return null;
}
The fix itself is simple. Once you have the dimensions:
// ❌ Causes CLS — browser doesn't know the space to reserve
<img src="/hero.jpg" alt="Hero" />
// ✅ Browser can calculate and reserve space before the image loads
<img
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
style={{ width: "100%", height: "auto" }} // CSS makes it responsive
/>
// ✅ Or with CSS aspect-ratio (more flexible for dynamic images)
<img
src="/hero.jpg"
alt="Hero"
style={{ aspectRatio: "16 / 9", width: "100%", height: "auto" }}
/>
Step 4: Prevent CLS With Skeleton Screens
Dynamic content — product listings, user feeds, comment sections — that loads after the initial render is a primary CLS source. The solution is to reserve the space before the content arrives. Skeleton screens are the right tool for this.
The getAspectRatio utility makes this accurate and data-driven when you know the expected content dimensions:
// components/ProductCard.tsx
"use client";
import { useState, useEffect } from "react";
import { useCLS } from "@page-speed/hooks";
interface Product {
id: string;
name: string;
description: string;
image: string;
imageWidth: number;
imageHeight: number;
price: number;
}
function SkeletonCard({ aspectRatio }: { aspectRatio: string }) {
return (
// Reserve exact space using the known aspect ratio
<div style={{ aspectRatio, width: "100%" }}>
<div
style={{
height: "70%",
background: "linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)",
backgroundSize: "200% 100%",
animation: "shimmer 1.5s infinite",
borderRadius: 8,
}}
/>
<div style={{ padding: "12px 0" }}>
<div style={{ height: 20, background: "#f0f0f0", borderRadius: 4, marginBottom: 8 }} />
<div style={{ height: 16, background: "#f0f0f0", borderRadius: 4, width: "60%" }} />
</div>
</div>
);
}
export function ProductCard({ productId }: { productId: string }) {
const [product, setProduct] = useState<Product | null>(null);
const { utils } = useCLS();
useEffect(() => {
fetch(`/api/products/${productId}`)
.then((r) => r.json())
.then(setProduct);
}, [productId]);
// While loading, show a skeleton that matches the expected dimensions.
// A standard 4:3 product image ratio is a safe default.
if (!product) {
return <SkeletonCard aspectRatio="4 / 3" />;
}
// Use the utility to compute the exact ratio from the real image dimensions
const { ratio } = utils.getAspectRatio(product.imageWidth, product.imageHeight);
return (
<div>
<img
src={product.image}
alt={product.name}
width={product.imageWidth}
height={product.imageHeight}
style={{ aspectRatio: ratio, width: "100%", height: "auto" }}
loading="lazy"
decoding="async"
/>
<h3>{product.name}</h3>
<p>{product.description}</p>
<strong>${product.price.toFixed(2)}</strong>
</div>
);
}
The key insight: the skeleton occupies exactly the same space as the real content, so when the content replaces it, no layout shift occurs.
Step 5: Handle SPA Navigation with utils.reset()
In single-page applications, CLS accumulates across route changes. A user visiting your home page followed by a product page will see a combined CLS score that doesn't reflect either page's actual stability. The utils.reset() function clears all tracked state and restarts measurement — exactly what you need on navigation.
// components/SPACLSTracker.tsx
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation"; // or useLocation from react-router-dom
import { useCLS } from "@page-speed/hooks";
export function SPACLSTracker() {
const pathname = usePathname();
const { cls, rating, utils } = useCLS({
reportAllChanges: false, // Only report final values per route
onMeasure: (value, rating) => {
// This fires with the final CLS for the current route
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
metric: "CLS",
value,
rating,
route: window.location.pathname,
timestamp: Date.now(),
}),
});
},
});
useEffect(() => {
// On route change: report the final CLS for the outgoing page
// (onMeasure handles this automatically via web-vitals)
// then reset for the incoming page
return () => {
utils.reset();
};
}, [pathname, utils]);
return null;
}
Step 6: Detect and Fix Web Font Shifts
Web fonts that use font-display: swap load a system font first, then swap in the web font once it's available. If the two fonts have different metrics (line height, letter spacing, ascent), the text reflows and causes a layout shift. The hook detects this as a web-font-shift issue.
// components/FontMonitor.tsx
"use client";
import { useCLS } from "@page-speed/hooks";
export function FontMonitor() {
const { issues } = useCLS({
onIssue: (issue) => {
if (issue.type === "web-font-shift") {
console.warn(
"[CLS] Web font layout shift detected!\n\n" +
"Choose one of these fixes:\n\n" +
"1. Use font-display: optional (no shift, but font may not show on first load)\n\n" +
"2. Use font-display: swap WITH font metric overrides:\n" +
" @font-face {\n" +
" font-family: 'FallbackForMyFont';\n" +
" src: local('Georgia');\n" +
" size-adjust: 106%;\n" +
" ascent-override: 90%;\n" +
" descent-override: 27%;\n" +
" }\n\n" +
"3. Preload the font in <head>:\n" +
" /fonts/my-font.woff2' as='font' crossOrigin='anonymous' />"
);
}
},
});
return null;
}
The CSS fix using font metric overrides is the most effective long-term solution:
/* In your global CSS or @font-face declarations */
/* The web font as you already have it */
@font-face {
font-family: "MyFont";
src: url("/fonts/myfont.woff2") format("woff2");
font-display: swap;
}
/* A tuned fallback that closely matches MyFont's metrics */
@font-face {
font-family: "MyFont-Fallback";
src: local("Georgia"); /* or whatever your fallback is */
size-adjust: 106%;
ascent-override: 90%;
descent-override: 27%;
}
/* Use both in your font stack */
body {
font-family: "MyFont", "MyFont-Fallback", serif;
}
Step 7: Prevent Animation-Related Shifts
CSS animations that modify layout-affecting properties (top, left, width, height, margin, padding) cause layout shifts. The hook detects these as animation-shift issues. The fix is always to use transform instead:
// The onIssue callback will tell you which element, but the fix is universal:
onIssue: (issue) => {
if (issue.type === "animation-shift") {
console.error(
`[CLS] Animation causing layout shift on: ${issue.element}\n` +
"Fix: Replace layout-affecting properties with transform.\n\n" +
"❌ Bad: transition: left 0.3s ease;\n" +
" left: 0px → left: 100px\n\n" +
"✅ Good: transition: transform 0.3s ease;\n" +
" transform: translateX(0) → transform: translateX(100px)"
);
}
}
/* ❌ Causes layout shifts */
.slide-in {
animation: badSlide 0.3s ease;
}
@keyframes badSlide {
from { left: -100px; }
to { left: 0; }
}
/* ✅ Zero CLS — transform is GPU-composited, doesn't affect layout */
.slide-in {
animation: goodSlide 0.3s ease;
}
@keyframes goodSlide {
from { transform: translateX(-100px); }
to { transform: translateX(0); }
}
Step 8: Reserve Space for Ads and Embeds
Ad slots and third-party embeds are notorious CLS sources because they load after the page renders and often expand to sizes larger than their initial space. The hook detects these as ad-embed-shift issues:
// components/AdSlot.tsx
"use client";
import { useCLS } from "@page-speed/hooks";
interface AdSlotProps {
slotId: string;
format: "banner" | "rectangle" | "leaderboard";
}
const adDimensions = {
banner: { width: 468, height: 60 },
rectangle: { width: 300, height: 250 },
leaderboard: { width: 728, height: 90 },
};
export function AdSlot({ slotId, format }: AdSlotProps) {
const { width, height } = adDimensions[format];
useCLS({
onIssue: (issue) => {
if (issue.type === "ad-embed-shift") {
console.warn(`Ad slot ${slotId} caused a layout shift of ${issue.contribution.toFixed(4)}`);
}
},
});
return (
<div
id={slotId}
data-ad={format}
style={{
// CRITICAL: Reserve the exact space before the ad loads
width,
minHeight: height,
// Prevent the slot from collapsing to 0 if the ad doesn't fill
background: "#f9f9f9",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* Ad network script loads here */}
</div>
);
}
Step 9: Build a Complete CLS Report for Stakeholders
When you need to present CLS data to a product team or client, the hook's rich state makes it easy to generate a structured report:
// components/CLSReporter.tsx
"use client";
import { useCallback } from "react";
import { useCLS } from "@page-speed/hooks";
import type { CLSIssue, LayoutShiftEntry } from "@page-speed/hooks";
interface CLSReport {
score: number | null;
rating: string | null;
totalShifts: number;
largestShiftValue: number | undefined;
largestShiftElements: (string | null)[] | undefined;
sessionWindowCount: number;
issues: Array<{
type: CLSIssue["type"];
element: string | null;
contribution: number;
suggestion: string;
}>;
recommendations: string[];
timestamp: string;
}
export function CLSReporter({ onReport }: { onReport?: (report: CLSReport) => void }) {
const {
cls,
rating,
entries,
issues,
largestShift,
sessionWindows,
shiftCount,
utils,
} = useCLS({
reportAllChanges: true,
onMeasure: (value, rating) => {
if (rating === "poor") {
console.error(`🔴 Poor CLS detected: ${value.toFixed(3)}`);
}
},
onShift: (entry) => {
if (entry.value > 0.05) {
console.warn(
"⚠ Significant layout shift:",
entry.sources.map((s) => s.node).filter(Boolean)
);
}
},
onIssue: (issue) => {
console.info(`💡 CLS Issue [${issue.type}]:`, issue.suggestion);
},
});
const generateReport = useCallback((): CLSReport => {
const report: CLSReport = {
score: cls,
rating,
totalShifts: shiftCount,
largestShiftValue: largestShift?.value,
largestShiftElements: largestShift?.sources.map((s) => s.node),
sessionWindowCount: sessionWindows.length,
issues: issues.map((i) => ({
type: i.type,
element: i.element,
contribution: i.contribution,
suggestion: i.suggestion,
})),
recommendations: issues.map((i) => i.suggestion),
timestamp: new Date().toISOString(),
};
onReport?.(report);
console.table(report.issues);
return report;
}, [cls, rating, shiftCount, largestShift, sessionWindows, issues, onReport]);
return (
<div className="cls-reporter">
<div
className={`cls-score ${rating}`}
style={{
display: "inline-flex",
gap: 8,
alignItems: "center",
padding: "8px 16px",
borderRadius: 8,
background:
rating === "good"
? "#0cce6b22"
: rating === "poor"
? "#ff4e4222"
: "#ffa40022",
}}
>
<span>CLS Score:</span>
<strong style={{ fontSize: 24 }}>{cls?.toFixed(3) ?? "..."}</strong>
{rating && (
<span style={{ textTransform: "capitalize", opacity: 0.8 }}>
({rating.replace("-", " ")})
</span>
)}
</div>
<div style={{ marginTop: 16 }}>
<button onClick={generateReport}>Generate Full Report</button>
</div>
{issues.length > 0 && (
<details style={{ marginTop: 16 }}>
<summary>
{issues.length} Optimization{issues.length !== 1 ? "s" : ""} Available
</summary>
<ul>
{issues.map((issue, i) => (
>
de style={{ background: "#f0f0f0", padding: "2px 6px", borderRadius: 3 }}>
{issue.type}
</code>
<p style={{ margin: "4px 0 0", fontSize: 14, color: "#555" }}>
{issue.suggestion}
</p>
</li>
))}
</ul>
</details>
)}
</div>
);
}
Understanding the Test Suite
The useCLS.test.ts file demonstrates several important testing patterns that are worth understanding if you're building similar hooks.
Mocking web-vitals
The tests mock the entire web-vitals module and capture the callback that useCLS passes to onCLS:
vi.mock("web-vitals", () => ({ onCLS: vi.fn() }));
// Inside tests:
const callback = mockOnCLS.mock.calls; // capture the registered callback
act(() => {
callback({ value: 0.05, entries: [] }); // simulate a CLS measurement
});
This pattern lets you test the hook's internal logic without any real browser performance APIs.
Mocking PerformanceObserver
The test suite stubs the global PerformanceObserver with a Vitest mock and verifies:
- That
observeis called with{ type: "layout-shift", buffered: true } - That
disconnectis called when the hook unmounts (no memory leaks) - That no observer is created when
trackAttribution: falseis passed
Rating Boundary Tests
The rating tests explicitly verify the exact threshold boundaries that match the web.dev specification:
it("calculates correct rating for good CLS", async () => {
callback({ value: 0.08, entries: [] });
expect(result.current.rating).toBe("good"); // 0.08 ≤ 0.1
});
it("calculates correct rating for needs-improvement CLS", async () => {
callback({ value: 0.15, entries: [] });
expect(result.current.rating).toBe("needs-improvement"); // 0.1 < 0.15 ≤ 0.25
});
it("calculates correct rating for poor CLS", async () => {
callback({ value: 0.3, entries: [] });
expect(result.current.rating).toBe("poor"); // 0.3 > 0.25
});
Performance Characteristics
useCLS is designed with zero rendering overhead in mind:
-
No polling or intervals — entirely event-driven via
PerformanceObserverand theweb-vitalslibrary -
Memoized utilities —
getElementSelector,hasExplicitDimensions,getAspectRatio, andresetare all wrapped inuseCallbackand assembled inuseMemoto produce a stableutilsreference that won't cause re-renders in child components -
Ref-based mutation —
entriesRef,sessionWindowsRef, andissuesRefaccumulate data without triggering renders; onlysetStatecalls when the CLS measurement updates -
Cleanup on unmount — The
PerformanceObserveris disconnected in theuseEffectcleanup function, preventing memory leaks in SPAs
Bundle impact when imported individually via the tree-shakeable /web-vitals subpath is a small fraction of the 12 KB full-library budget.
Quick-Reference: Common Fixes
| CLS Cause | Hook Detection | The Fix |
|---|---|---|
Image without width/height
|
image-without-dimensions issue |
Add width + height attrs, or CSS aspect-ratio
|
| Video/media without dimensions |
unsized-media issue |
Add width + height to media elements |
| Dynamic content insertion |
dynamic-content issue |
Skeleton screens, min-height containers |
| Web font swap |
web-font-shift issue |
font-display: optional or font metric overrides |
| Ad/embed expansion |
ad-embed-shift issue |
Fixed min-height on ad containers |
| Layout-affecting animation |
animation-shift issue |
Replace top/left/width/height with transform
|
Getting Started
# Install
pnpm add @page-speed/hooks
# Or for just the web vitals hooks (smaller bundle)
# Import from subpath in your code:
# import { useCLS } from "@page-speed/hooks/web-vitals";
GitHub Repository: opensite-ai/page-speed-hooks
npm Package: @page-speed/hooks
Built by: OpenSite AI
Top comments (0)