Performance-optimized React hooks for Core Web Vitals, responsive images, lazy loading, and resource management. Drop-in implementations of web.dev best practices with zero configuration.
npm install @page-speed/hooks---
Drop-in implementations of web.dev best practices with zero configuration.





Documentation · Quick Start · Hooks · Examples · Contributing
---
Web.dev provides excellent guidance on optimizing Core Web Vitals, but implementing those recommendations requires boilerplate code and careful attention to performance details. @page-speed/hooks eliminates that gap.
Our hooks are:
- ✅ Zero Configuration - Works out of the box with sensible defaults
- ✅ Tree-Shakeable - Only bundle what you use (~2-3 KB per hook)
- ✅ TypeScript-First - Complete type definitions and JSDoc
- ✅ web.dev Aligned - Implements official best practices exactly
- ✅ Production Ready - Used internally at OpenSite for performance-critical applications
- ✅ Framework Agnostic - Core logic works in any React environment (Next.js, Remix, etc.)
- ✅ SSR Compatible - Works seamlessly with server-side rendering
---
``bash`
npm install @page-speed/hooksor
pnpm add @page-speed/hooksor
yarn add @page-speed/hooks
`tsx
import { useWebVitals } from "@page-speed/hooks";
function App() {
const vitals = useWebVitals({
onLCP: (metric) => analytics.track("LCP", metric.value),
onCLS: (metric) => analytics.track("CLS", metric.value),
reportAllChanges: true,
});
return (
LCP: {vitals.lcp ? ${vitals.lcp.toFixed(0)}ms : "Measuring..."}
CLS: {vitals.cls ? vitals.cls.toFixed(3) : "Measuring..."}
INP: {vitals.inp ? ${vitals.inp.toFixed(0)}ms : "Measuring..."}
---
Hooks
$3
####
useWebVitals(options?)Tracks all Core Web Vitals metrics (LCP, CLS, INP) plus additional metrics (FCP, TTFB).
`tsx
import { useWebVitals } from "@page-speed/hooks";function App() {
const vitals = useWebVitals({
onLCP: (metric) => console.log("LCP:", metric.value),
onCLS: (metric) => console.log("CLS:", metric.value),
onINP: (metric) => console.log("INP:", metric.value),
reportAllChanges: true,
});
return (
Core Web Vitals
- LCP: {vitals.lcp ? ${vitals.lcp.toFixed(0)}ms
: "—"}
- CLS: {vitals.cls ? vitals.cls.toFixed(3) : "—"}
- INP: {vitals.inp ? ${vitals.inp.toFixed(0)}ms
: "—"}
);
}
`Options:
-
onLCP?: (metric) => void - Called when LCP is measured
- onCLS?: (metric) => void - Called when CLS is measured
- onINP?: (metric) => void - Called when INP is measured
- onFCP?: (metric) => void - Called when FCP is measured
- onTTFB?: (metric) => void - Called when TTFB is measured
- reportAllChanges?: boolean - Report all changes, not just final valuesReturns:
`typescript
{
lcp: number | null; // Largest Contentful Paint (ms)
cls: number | null; // Cumulative Layout Shift (unitless)
inp: number | null; // Interaction to Next Paint (ms)
fcp: number | null; // First Contentful Paint (ms)
ttfb: number | null; // Time to First Byte (ms)
isLoading: boolean; // Measurements in progress
}
`Web.dev References:
- LCP - Largest Contentful Paint
- CLS - Cumulative Layout Shift
- INP - Interaction to Next Paint
---
####
useLCP(options?)Optimizes Largest Contentful Paint by tracking the LCP element and automatically setting
fetchpriority="high" for likely LCP images.`tsx
import { useLCP } from "@page-speed/hooks";function Hero() {
const { ref, fetchPriority, lcp, rating } = useLCP({
threshold: 2500,
onMeasure: (value, rating) => {
console.log(
LCP: ${value}ms (${rating}));
},
}); return (
ref={ref}
fetchPriority={fetchPriority}
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
/>
);
}
`Options:
-
threshold?: number - Target LCP in milliseconds (default: 2500)
- onMeasure?: (value, rating) => void - Called when LCP is measured
- reportAllChanges?: boolean - Report all changesReturns:
`typescript
{
ref: (node) => void // Attach to element
fetchPriority: 'high' | undefined // Suggested fetch priority
lcp: number | null // Current LCP (ms)
rating: 'good' | 'needs-improvement' | 'poor' | null
isLCP: boolean // Element is likely LCP
isLoading: boolean // Measurement in progress
}
`LCP Thresholds (web.dev):
- Good: ≤ 2.5s
- Needs Improvement: 2.5s - 4.0s
- Poor: > 4.0s
Web.dev Reference: Optimize LCP
---
####
useCLS(options?)Comprehensive hook for tracking, analyzing, and optimizing Cumulative Layout Shift (CLS). Provides real-time measurement, layout shift attribution, automatic issue detection, and optimization utilities.
`tsx
import { useCLS } from "@page-speed/hooks";function App() {
const { cls, rating, entries, issues, largestShift, utils } = useCLS({
threshold: 0.1,
onMeasure: (value, rating) => {
console.log(
CLS: ${value.toFixed(3)} (${rating}));
},
onIssue: (issue) => {
console.warn("CLS Issue:", issue.type, issue.suggestion);
},
}); return (
CLS: {cls?.toFixed(3) ?? "Measuring..."}
Rating: {rating}
Layout Shifts: {entries.length}
);
}
`Options:
-
threshold?: number - Target CLS threshold (default: 0.1)
- onMeasure?: (value, rating) => void - Called when CLS is measured
- onShift?: (entry) => void - Called on each layout shift
- onIssue?: (issue) => void - Called when optimization opportunity detected
- reportAllChanges?: boolean - Report all changes (default: false)
- debug?: boolean - Enable console warnings (default: true in development)
- detectIssues?: boolean - Enable automatic issue detection (default: true)
- trackAttribution?: boolean - Track which elements shifted (default: true)Returns:
`typescript
{
cls: number | null; // Current CLS value
rating: 'good' | 'needs-improvement' | 'poor' | null;
isLoading: boolean; // Measurement in progress
entries: LayoutShiftEntry[]; // Individual shift entries
largestShift: LayoutShiftEntry | null; // Biggest contributor
sessionWindows: CLSSessionWindow[]; // Grouped shift windows
largestSessionWindow: CLSSessionWindow | null;
issues: CLSIssue[]; // Detected optimization opportunities
shiftCount: number; // Total shifts detected
hasPostInteractionShifts: boolean; // Shifts after user interaction
utils: {
getElementSelector: (element) => string | null;
hasExplicitDimensions: (element) => boolean;
getAspectRatio: (width, height) => { ratio: string; decimal: number };
reset: () => void;
};
}
`CLS Thresholds (web.dev):
- Good: ≤ 0.1
- Needs Improvement: 0.1 - 0.25
- Poor: > 0.25
Web.dev Reference: Optimize CLS
---
##### CLS Use Cases
###### Basic CLS Monitoring
Track CLS and send to analytics:
`tsx
import { useCLS } from "@page-speed/hooks";function PerformanceMonitor() {
useCLS({
onMeasure: (value, rating) => {
// Send to your analytics service
analytics.track("CLS", {
value,
rating,
page: window.location.pathname,
});
},
});
return null; // Invisible monitoring component
}
`###### Real-time CLS Dashboard
Display live CLS metrics in development:
`tsx
import { useCLS } from "@page-speed/hooks";function CLSDashboard() {
const { cls, rating, shiftCount, largestShift, issues } = useCLS({
reportAllChanges: true,
});
return (
CLS Monitor
metric ${rating}}>
CLS Score:
{cls?.toFixed(4) ?? "—"}
Total Shifts: {shiftCount}
{largestShift && (
Largest Shift
Value: {largestShift.value.toFixed(4)}
Elements: {largestShift.sources.map((s) => s.node).join(", ")}
)} {issues.length > 0 && (
Optimization Opportunities
{issues.map((issue, i) => (
{issue.type}: {issue.suggestion}
))}
)}
);
}
`###### Detecting Images Without Dimensions
Automatically find images causing CLS:
`tsx
import { useCLS } from "@page-speed/hooks";function ImageAudit() {
const { issues, utils } = useCLS({
detectIssues: true,
onIssue: (issue) => {
if (issue.type === "image-without-dimensions") {
console.error(
🖼️ Image missing dimensions: ${issue.element}\n +
Suggestion: ${issue.suggestion}
);
}
},
}); // Find all images and check dimensions
const auditImages = () => {
document.querySelectorAll("img").forEach((img) => {
if (!utils.hasExplicitDimensions(img)) {
const { ratio } = utils.getAspectRatio(
img.naturalWidth,
img.naturalHeight
);
console.warn(
Image needs dimensions: ${utils.getElementSelector(img)}\n +
Add: width="${img.naturalWidth}" height="${img.naturalHeight}"\n +
Or CSS: aspect-ratio: ${ratio};
);
}
});
}; return ;
}
`###### Font Loading CLS Prevention
Monitor and optimize web font shifts:
`tsx
import { useCLS } from "@page-speed/hooks";function FontLoadingMonitor() {
const { issues } = useCLS({
onIssue: (issue) => {
if (issue.type === "web-font-shift") {
console.warn(
"Font-related layout shift detected!\n" +
"Consider:\n" +
"1. Preload critical fonts: \n" +
"2. Use font-display: optional\n" +
"3. Match fallback font metrics with size-adjust"
);
}
},
});
const fontIssues = issues.filter((i) => i.type === "web-font-shift");
return fontIssues.length > 0 ? (
⚠️ {fontIssues.length} font-related layout shifts detected
) : null;
}
`###### Ad Container Space Reservation
Prevent ad-related CLS:
`tsx
import { useCLS } from "@page-speed/hooks";function AdContainer({ slotId, minHeight = 250 }) {
const { issues } = useCLS({
onIssue: (issue) => {
if (
issue.type === "ad-embed-shift" &&
issue.element?.includes(slotId)
) {
console.warn(
Ad slot ${slotId} caused layout shift of ${issue.contribution});
}
},
}); return (
id={slotId}
style={{
minHeight: ${minHeight}px,
width: "100%",
backgroundColor: "#f0f0f0",
}}
>
{/ Ad loads here /}
###### SPA Navigation CLS Reset
Reset CLS tracking on route changes:
`tsx
import { useCLS } from "@page-speed/hooks";
import { useEffect } from "react";
import { useLocation } from "react-router-dom";function SPACLSTracker() {
const location = useLocation();
const { cls, rating, utils } = useCLS({
onMeasure: (value, rating) => {
analytics.track("CLS", {
value,
rating,
route: location.pathname,
});
},
});
// Reset CLS tracking on route change
useEffect(() => {
utils.reset();
}, [location.pathname, utils]);
return null;
}
`###### Conditional Rendering with Skeleton
Prevent CLS with skeleton screens:
`tsx
import { useCLS } from "@page-speed/hooks";
import { useState, useEffect } from "react";function ProductCard({ productId }) {
const [product, setProduct] = useState(null);
const { utils } = useCLS();
useEffect(() => {
fetchProduct(productId).then(setProduct);
}, [productId]);
// Skeleton maintains layout while loading
if (!product) {
return (
);
} // Calculate recommended aspect ratio
const { ratio } = utils.getAspectRatio(product.imageWidth, product.imageHeight);
return (
src={product.image}
alt={product.name}
width={product.imageWidth}
height={product.imageHeight}
style={{ aspectRatio: ratio }}
/>
{product.name}
{product.description}
);
}
`###### Animation Shift Detection
Detect animations causing CLS:
`tsx
import { useCLS } from "@page-speed/hooks";function AnimationMonitor() {
const { issues } = useCLS({
onIssue: (issue) => {
if (issue.type === "animation-shift") {
console.warn(
Animation causing layout shift on ${issue.element}\n +
"Fix: Use transform-based animations instead of top/left/width/height\n" +
"Bad: animation: slide { from { left: 0 } to { left: 100px } }\n" +
"Good: animation: slide { from { transform: translateX(0) } to { transform: translateX(100px) } }"
);
}
},
}); return null;
}
`###### Complete CLS Optimization Component
Full-featured CLS monitoring and reporting:
`tsx
import { useCLS } from "@page-speed/hooks";
import { useCallback } from "react";function CLSOptimizer({ onReport }) {
const {
cls,
rating,
entries,
issues,
largestShift,
sessionWindows,
shiftCount,
utils,
} = useCLS({
threshold: 0.1,
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);
}
},
onIssue: (issue) => {
console.info(CLS Issue [${issue.type}]:, issue.suggestion);
},
}); const generateReport = useCallback(() => {
const report = {
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),
};
onReport?.(report);
return report;
}, [cls, rating, shiftCount, largestShift, sessionWindows, issues, onReport]);
return (
cls-score cls-${rating}}>
CLS
{cls?.toFixed(3) ?? "..."}
{rating}
{issues.length > 0 && (
{issues.length} Optimization{issues.length > 1 ? "s" : ""} Available
{issues.map((issue, i) => (
{issue.type}
{issue.suggestion}
))}
)}
);
}
`---
####
useINP(options?)Comprehensive hook for tracking, analyzing, and optimizing Interaction to Next Paint (INP). Provides real-time measurement, phase breakdown analysis, issue detection, and optimization utilities.
`tsx
import { useINP } from "@page-speed/hooks";function App() {
const { inp, rating, slowestInteraction, issues, utils } = useINP({
threshold: 200,
onMeasure: (value, rating) => {
console.log(
INP: ${value}ms (${rating}));
},
onIssue: (issue) => {
console.warn("INP Issue:", issue.type, issue.suggestion);
},
}); return (
INP: {inp ? ${inp.toFixed(0)}ms : "Measuring..."}
Rating: {rating}
{slowestInteraction && (
Slowest interaction: {slowestInteraction.target}
Input Delay: {slowestInteraction.phases.inputDelay.toFixed(0)}ms
Processing: {slowestInteraction.phases.processingDuration.toFixed(0)}ms
Presentation: {slowestInteraction.phases.presentationDelay.toFixed(0)}ms
)}
);
}
`Options:
-
threshold?: number - Target INP in milliseconds (default: 200)
- onMeasure?: (value, rating) => void - Called when INP is measured
- onInteraction?: (interaction) => void - Called on each interaction
- onIssue?: (issue) => void - Called when optimization opportunity detected
- reportAllChanges?: boolean - Report all interactions (default: false)
- debug?: boolean - Enable console warnings (default: true in development)
- detectIssues?: boolean - Enable automatic issue detection (default: true)
- trackAttribution?: boolean - Track interaction attribution (default: true)Returns:
`typescript
{
inp: number | null; // Current INP (ms)
rating: 'good' | 'needs-improvement' | 'poor' | null;
isLoading: boolean; // Measurement in progress
interactions: INPInteraction[]; // All tracked interactions
slowestInteraction: INPInteraction | null; // Worst interaction
slowestPhases: INPPhaseBreakdown | null; // Phase breakdown of slowest
issues: INPIssue[]; // Detected optimization opportunities
interactionCount: number; // Total interactions
slowInteractionCount: number; // Interactions exceeding threshold
averageLatency: number | null; // Average across all interactions
goodInteractionPercentage: number; // % rated "good"
interactionsByType: { click, keypress, tap }; // Distribution by type
utils: {
getElementSelector: (element) => string | null;
isThirdPartyScript: (url) => boolean;
getSuggestions: (interaction) => string[];
reset: () => void;
recordInteraction: (latency, target?, type?) => void;
};
}
`INP Thresholds (web.dev):
- Good: ≤ 200ms
- Needs Improvement: 200ms - 500ms
- Poor: > 500ms
Web.dev Reference: Optimize INP
---
##### INP Use Cases
###### Basic INP Monitoring
Track INP and send to analytics:
`tsx
import { useINP } from "@page-speed/hooks";function PerformanceMonitor() {
useINP({
onMeasure: (value, rating) => {
// Send to your analytics service
analytics.track("INP", {
value,
rating,
page: window.location.pathname,
});
},
});
return null; // Invisible monitoring component
}
`###### Real-time INP Dashboard
Display live INP metrics in development:
`tsx
import { useINP } from "@page-speed/hooks";function INPDashboard() {
const {
inp,
rating,
interactionCount,
slowestInteraction,
slowestPhases,
issues,
} = useINP({
reportAllChanges: true,
});
return (
INP Monitor
metric ${rating}}>
INP Score:
{inp ? ${inp.toFixed(0)}ms : "—"}
Total Interactions: {interactionCount}
{slowestInteraction && slowestPhases && (
Slowest Interaction
Target: {slowestInteraction.target}
Type: {slowestInteraction.type}
- Input Delay: {slowestPhases.inputDelay.toFixed(0)}ms
- Processing: {slowestPhases.processingDuration.toFixed(0)}ms
- Presentation: {slowestPhases.presentationDelay.toFixed(0)}ms
)} {issues.length > 0 && (
Optimization Opportunities
{issues.map((issue, i) => (
{issue.type}: {issue.suggestion}
))}
)}
);
}
`###### Phase Breakdown Analysis
Identify which phase of interaction is causing slowness:
`tsx
import { useINP } from "@page-speed/hooks";function INPPhaseAnalyzer() {
const { slowestInteraction, slowestPhases, utils } = useINP({
onInteraction: (interaction) => {
const { phases } = interaction;
// Determine the bottleneck phase
if (phases.inputDelay > 50) {
console.warn(
High input delay (${phases.inputDelay.toFixed(0)}ms) on ${interaction.target}.\n +
"The main thread was busy. Consider:\n" +
"- Breaking up long tasks with scheduler.yield()\n" +
"- Using requestIdleCallback for non-critical work\n" +
"- Code splitting to reduce JavaScript on page load"
);
} if (phases.processingDuration > 100) {
console.warn(
Slow event handler (${phases.processingDuration.toFixed(0)}ms) on ${interaction.target}.\n +
"Consider:\n" +
"- Debouncing/throttling event handlers\n" +
"- Moving heavy computation to web workers\n" +
"- Deferring non-visual work with setTimeout"
);
} if (phases.presentationDelay > 50) {
console.warn(
High presentation delay (${phases.presentationDelay.toFixed(0)}ms) on ${interaction.target}.\n +
"Consider:\n" +
"- Reducing DOM size\n" +
"- Using content-visibility: auto for off-screen content\n" +
"- Simplifying CSS selectors"
);
}
},
}); return null;
}
`###### Third-Party Script Impact Detection
Identify third-party scripts affecting INP:
`tsx
import { useINP } from "@page-speed/hooks";function ThirdPartyMonitor() {
const { topSlowScripts, utils } = useINP({
trackAttribution: true,
onIssue: (issue) => {
if (issue.type === "third-party-script") {
console.warn(
Third-party script impacting INP:\n +
Script: ${issue.scriptURL}\n +
Impact: ${issue.contribution.toFixed(0)}ms\n +
Suggestion: ${issue.suggestion}
);
}
},
}); // Display scripts causing slow interactions
return topSlowScripts.length > 0 ? (
Scripts Impacting Responsiveness
{topSlowScripts.map((script, i) => (
{script.url}
Total: {script.totalDuration.toFixed(0)}ms
({script.occurrences} occurrences)
{script.isThirdParty && 3rd Party}
))}
) : null;
}
`###### SPA Navigation INP Reset
Reset INP tracking on route changes:
`tsx
import { useINP } from "@page-speed/hooks";
import { useEffect } from "react";
import { useLocation } from "react-router-dom";function SPAINPTracker() {
const location = useLocation();
const { inp, rating, utils } = useINP({
onMeasure: (value, rating) => {
analytics.track("INP", {
value,
rating,
route: location.pathname,
});
},
});
// Reset INP tracking on route change
useEffect(() => {
utils.reset();
}, [location.pathname, utils]);
return null;
}
`###### Complete INP Optimization Component
Full-featured INP monitoring and reporting:
`tsx
import { useINP } from "@page-speed/hooks";
import { useCallback } from "react";function INPOptimizer({ onReport }) {
const {
inp,
rating,
interactions,
issues,
slowestInteraction,
slowestPhases,
interactionCount,
slowInteractionCount,
averageLatency,
goodInteractionPercentage,
interactionsByType,
utils,
} = useINP({
threshold: 200,
reportAllChanges: true,
onMeasure: (value, rating) => {
if (rating === "poor") {
console.error(
Poor INP detected: ${value.toFixed(0)}ms);
}
},
onInteraction: (interaction) => {
if (interaction.latency > 500) {
console.warn("Very slow interaction:", interaction.target);
console.warn("Suggestions:", utils.getSuggestions(interaction));
}
},
onIssue: (issue) => {
console.info(INP Issue [${issue.type}]:, issue.suggestion);
},
}); const generateReport = useCallback(() => {
const report = {
score: inp,
rating,
totalInteractions: interactionCount,
slowInteractions: slowInteractionCount,
averageLatency,
goodPercentage: goodInteractionPercentage,
distribution: interactionsByType,
slowestTarget: slowestInteraction?.target,
slowestLatency: slowestInteraction?.latency,
phases: slowestPhases,
issues: issues.map((i) => ({
type: i.type,
element: i.element,
contribution: i.contribution,
suggestion: i.suggestion,
})),
recommendations: issues.map((i) => i.suggestion),
};
onReport?.(report);
return report;
}, [
inp, rating, interactionCount, slowInteractionCount,
averageLatency, goodInteractionPercentage, interactionsByType,
slowestInteraction, slowestPhases, issues, onReport
]);
return (
inp-score inp-${rating}}>
INP
{inp ? ${inp.toFixed(0)}ms : "..."}
{rating}
Interactions: {interactionCount} ({goodInteractionPercentage.toFixed(0)}% good)
Average: {averageLatency ? ${averageLatency.toFixed(0)}ms : "—"}
{issues.length > 0 && (
{issues.length} Optimization{issues.length > 1 ? "s" : ""} Available
{issues.map((issue, i) => (
{issue.type}
{issue.suggestion}
))}
)}
);
}
`---
$3
####
useOptimizedImage(options)Lazy loads images below the fold with IntersectionObserver, automatically deferring loading until the element is visible. Optionally integrates with OptixFlow for automatic image optimization including compression, format conversion, responsive srcset generation, and DPR-aware sizing.
Implements web.dev best practices for:
- Pixel-perfect sizing for Lighthouse "Properly size images" audit
- DPR-aware srcset with 1x and 2x variants
- Format negotiation with AVIF, WebP, and JPEG fallback via
element
- CLS prevention through explicit dimensions`tsx
import { useOptimizedImage } from "@page-speed/hooks/media";function ProductImage() {
const { ref, src, isLoaded, loading, size } = useOptimizedImage({
src: "/product.jpg",
eager: false,
threshold: 0.1,
rootMargin: "50px",
});
return (
ref={ref}
src={src}
loading={loading}
className={isLoaded ? "loaded" : "loading"}
alt="Product"
width={size.width}
height={size.height}
/>
);
}
`With OptixFlow Integration (Recommended):
Use the
element with responsive srcset for optimal Lighthouse scores:`tsx
import { useOptimizedImage } from "@page-speed/hooks/media";function OptimizedProductImage() {
const { ref, src, srcset, sizes, isLoaded, loading, size } = useOptimizedImage({
src: "https://example.com/product.jpg",
width: 480,
height: 300,
optixFlowConfig: {
apiKey: "your-optixflow-api-key",
compressionLevel: 80,
objectFit: "cover",
},
});
return (
{/ Modern formats with responsive srcset /}
{/ Fallback with pixel-perfect sizing for Lighthouse /}
ref={ref}
src={src}
loading={loading}
className={isLoaded ? "loaded" : "loading"}
alt="Product"
width={size.width}
height={size.height}
decoding="async"
/>
);
}
`Options:
-
src: string - Image source URL (required)
- eager?: boolean - Load immediately (default: false)
- threshold?: number - IntersectionObserver threshold (default: 0.1)
- rootMargin?: string - IntersectionObserver root margin (default: '50px')
- width?: number - Explicit width in pixels (overrides detected width)
- height?: number - Explicit height in pixels (overrides detected height)
- optixFlowConfig?: object - OptixFlow API configuration:
- apiKey: string - Your OptixFlow API key (required to enable)
- compressionLevel?: number - Quality 0-100 (default: 75)
- renderedFileType?: 'avif' | 'webp' | 'jpeg' | 'png' - Primary src format (default: 'jpeg')
- objectFit?: 'cover' | 'contain' | 'fill' - Resize behavior (default: 'cover')Returns:
`typescript
{
ref: (node) => void // Attach to img element
src: string // Primary src with exact rendered dimensions (Lighthouse-optimized)
srcset: { // Responsive srcset for each format
avif: string; // AVIF srcset with 1x and 2x DPR variants
webp: string; // WebP srcset with 1x and 2x DPR variants
jpeg: string; // JPEG srcset with 1x and 2x DPR variants
}
sizes: string // Sizes attribute value (e.g., "480px")
isLoaded: boolean // Image has loaded
isInView: boolean // Element is in viewport
loading: 'lazy' | 'eager' // Loading strategy used
size: { width, height } // Current rendered dimensions (dynamic)
}
`Srcset Format:
Each format's srcset includes 1x and 2x DPR variants:
`
url?w=480&h=300&f=avif 1x, url?w=960&h=600&f=avif 2x
`Best Practices:
- Use
eager={true} for above-fold images (hero, header)
- Use eager={false} (default) for below-fold images
- Use the element with srcset for optimal format negotiation
- Always include width and height to prevent CLS
- Increase rootMargin to preload before user reaches image
- Set threshold lower for early loading (0.01) or higher for exact visibility (0.5)
- The size property updates dynamically via ResizeObserver as images resize
- Use decoding="async" on the for better performance---
$3
####
useDeferredMount(options?)Defers mounting expensive components until after the page is idle, improving Core Web Vitals and initial load performance.
`tsx
import { useDeferredMount } from "@page-speed/hooks/resources";function HeavyComponent() {
const shouldRender = useDeferredMount({
delay: 100,
priority: "low",
});
if (!shouldRender) {
return ;
}
return ;
}
export default function Page() {
return (
{/ Won't render until page is idle /}
);
}
`Options:
-
delay?: number - Additional delay after idle (ms, default: 0)
- priority?: 'low' | 'high' - Use requestIdleCallback (default: 'low')Returns:
boolean - Whether the component should renderHow It Works:
1.
priority: 'low' uses requestIdleCallback (waits for browser idle time)
2. Adds optional delay for extra safety
3. Falls back to setTimeout on older browsers
4. Perfect for non-critical features: analytics, chat widgets, adsWeb.dev Reference: Optimize Interaction to Next Paint (INP)
---
Examples
$3
`tsx
// app/layout.tsx
"use client";import { useWebVitals } from "@page-speed/hooks";
import { useEffect } from "react";
export default function RootLayout({ children }) {
useWebVitals({
onLCP: (metric) => {
// Send to analytics
fetch("/api/analytics", {
method: "POST",
body: JSON.stringify({ metric: "LCP", value: metric.value }),
});
},
reportAllChanges: true,
});
return (
{children}
);
}
`$3
`tsx
// app/routes/products.tsx
import { useWebVitals, useOptimizedImage } from "@page-speed/hooks";export default function Products() {
const vitals = useWebVitals();
const { ref, src, isLoaded } = useOptimizedImage({
src: "/product-image.jpg",
eager: false,
});
return (
Products
ref={ref}
src={src}
alt="Product"
className={isLoaded ? "visible" : "loading"}
/>
LCP: {vitals.lcp}ms
);
}
`$3
`tsx
// hooks/useAnalytics.ts
import { useWebVitals } from "@page-speed/hooks";
import { useCallback } from "react";export function useAnalytics() {
const trackVital = useCallback(
(metricName: string, value: number, rating: string) => {
// Send to Google Analytics
if (window.gtag) {
window.gtag("event", metricName, {
value: value,
rating: rating,
event_category: "web_vitals",
});
}
// Send to custom analytics
fetch("/api/vitals", {
method: "POST",
body: JSON.stringify({ metric: metricName, value, rating }),
});
},
[]
);
useWebVitals({
onLCP: (metric) => trackVital("LCP", metric.value, metric.rating),
onCLS: (metric) => trackVital("CLS", metric.value, metric.rating),
onINP: (metric) => trackVital("INP", metric.value, metric.rating),
reportAllChanges: true,
});
}
// app/layout.tsx
("use client");
import { useAnalytics } from "@/hooks/useAnalytics";
export default function RootLayout({ children }) {
useAnalytics();
return children;
}
`---
Tree-Shaking
@page-speed/hooks is built for maximum tree-shaking. Import only what you need:
`tsx
// ✅ Good: Import specific hooks
import { useLCP } from "@page-speed/hooks/web-vitals"; // ~2.8 KB
import { useOptimizedImage } from "@page-speed/hooks/media"; // ~2.1 KB// ✅ Also good: Import from main entry
import { useLCP, useOptimizedImage } from "@page-speed/hooks";
// ❌ Avoid: This imports everything
import * as hooks from "@page-speed/hooks";
`Bundle Impact:
- Full library: ~12 KB gzipped
-
useWebVitals only: ~3.2 KB gzipped
- useLCP only: ~2.8 KB gzipped
- useOptimizedImage only: ~2.1 KB gzipped
- useDeferredMount only: ~1.4 KB gzipped---
Metrics & Thresholds
All metrics follow web.dev standards:
$3
| Metric | Good | Needs Improvement | Poor |
| ----------------------------------- | ------- | ----------------- | ------- |
| LCP (Largest Contentful Paint) | ≤ 2.5s | 2.5s - 4.0s | > 4.0s |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | 0.1 - 0.25 | > 0.25 |
| INP (Interaction to Next Paint) | ≤ 200ms | 200ms - 500ms | > 500ms |
$3
| Metric | Good | Needs Improvement | Poor |
| -------------------------------- | ------- | ----------------- | -------- |
| FCP (First Contentful Paint) | ≤ 1.8s | 1.8s - 3.0s | > 3.0s |
| TTFB (Time to First Byte) | ≤ 800ms | 800ms - 1800ms | > 1800ms |
---
Browser Support
- ✅ Chrome/Edge 90+
- ✅ Firefox 88+
- ✅ Safari 14+
- ✅ Mobile browsers with Web Vitals support
Note: Gracefully degrades on older browsers with polyfills available.
---
Performance Impact
Adding @page-speed/hooks to your project:
- Bundle Size Impact: +2-12 KB (depending on hooks used)
- Runtime Overhead: Negligible (uses native APIs)
- Rendering Impact: Zero (hooks don't trigger renders)
- Network Impact: Zero (no external requests)
---
API Reference
$3
`tsx
// Full library
import {
useWebVitals,
useLCP,
useCLS,
useINP,
useOptimizedImage,
useDeferredMount,
} from "@page-speed/hooks";// Web Vitals only
import { useWebVitals, useLCP, useCLS, useINP } from "@page-speed/hooks/web-vitals";
// Media only
import { useOptimizedImage } from "@page-speed/hooks/media";
// Resources only
import { useDeferredMount } from "@page-speed/hooks/resources";
`$3
`tsx
import type {
// Core metric types
Metric,
WebVitalsOptions,
WebVitalsState,
// LCP types
LCPOptions,
LCPState,
// CLS types
CLSOptions,
CLSState,
LayoutShiftEntry,
LayoutShiftAttribution,
CLSSessionWindow,
CLSIssue,
// INP types
INPOptions,
INPState,
INPInteraction,
INPPhaseBreakdown,
INPScriptAttribution,
INPIssue,
INPIssueType,
INPInteractionType,
// Media types
UseOptimizedImageOptions,
UseOptimizedImageState,
SrcsetByFormat, // NEW: { avif: string, webp: string, jpeg: string }
ImageFormat, // NEW: "avif" | "webp" | "jpeg" | "png"
// Resource types
UseDeferredMountOptions,
} from "@page-speed/hooks";
`---
Troubleshooting
$3
Problem: useWebVitals shows all metrics as null
Solution: Metrics take time to measure. Make sure you:
1. Wait a few seconds after page load
2. Use
reportAllChanges: true for development
3. Check browser console for errors`tsx
useWebVitals({
reportAllChanges: true, // Report every update (for dev)
});
`$3
Problem: useOptimizedImage shows empty src
Solution: Make sure IntersectionObserver is supported:
`tsx
const { ref, src, isInView } = useOptimizedImage({ src: "/image.jpg" });// Add fallback src for visibility while loading
return
;
`$3
Problem: Component never appears
Solution: Check browser console for errors. useDeferredMount might need a longer delay:
`tsx
const shouldRender = useDeferredMount({
delay: 500, // Increase delay
priority: "high", // Try high priority
});
`---
Contributing
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
Development:
`bash
git clone https://github.com/opensite-ai/page-speed-hooks
cd page-speed-hooks
pnpm install
pnpm dev # Watch mode
pnpm test # Run tests
pnpm build # Production build
``---
BSD 3-Clause License © OpenSite AI
---
- web.dev - Official web performance guidance
- web-vitals - Official metrics library
- Core Web Vitals Guide - What are Core Web Vitals?
- CrUX Report - Real-world performance data
- Lighthouse - Performance testing tool
---
Built with ❤️ by OpenSite AI
Part of the @page-speed ecosystem for performance-first React development.
---
Have questions? Open an issue or check discussions