@page-speed/hooks
v0.4.5
Published
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.
Downloads
858
Readme
⚡ @page-speed/hooks
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.
Documentation · Quick Start · Hooks · Examples · Contributing
Why @page-speed/hooks?
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
Quick Start
Installation
npm install @page-speed/hooks
# or
pnpm add @page-speed/hooks
# or
yarn add @page-speed/hooksBasic Usage
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 (
<div>
<p>LCP: {vitals.lcp ? `${vitals.lcp.toFixed(0)}ms` : "Measuring..."}</p>
<p>CLS: {vitals.cls ? vitals.cls.toFixed(3) : "Measuring..."}</p>
<p>INP: {vitals.inp ? `${vitals.inp.toFixed(0)}ms` : "Measuring..."}</p>
</div>
);
}Hooks
📊 Web Vitals Tracking
useWebVitals(options?)
Tracks all Core Web Vitals metrics (LCP, CLS, INP) plus additional metrics (FCP, TTFB).
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 (
<div>
<h1>Core Web Vitals</h1>
<ul>
<li>LCP: {vitals.lcp ? `${vitals.lcp.toFixed(0)}ms` : "—"}</li>
<li>CLS: {vitals.cls ? vitals.cls.toFixed(3) : "—"}</li>
<li>INP: {vitals.inp ? `${vitals.inp.toFixed(0)}ms` : "—"}</li>
</ul>
</div>
);
}Options:
onLCP?: (metric) => void- Called when LCP is measuredonCLS?: (metric) => void- Called when CLS is measuredonINP?: (metric) => void- Called when INP is measuredonFCP?: (metric) => void- Called when FCP is measuredonTTFB?: (metric) => void- Called when TTFB is measuredreportAllChanges?: boolean- Report all changes, not just final values
Returns:
{
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:
useLCP(options?)
Optimizes Largest Contentful Paint by tracking the LCP element and automatically setting fetchpriority="high" for likely LCP images.
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 (
<img
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 measuredreportAllChanges?: boolean- Report all changes
Returns:
{
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.
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 (
<div>
<p>CLS: {cls?.toFixed(3) ?? "Measuring..."}</p>
<p>Rating: {rating}</p>
<p>Layout Shifts: {entries.length}</p>
</div>
);
}Options:
threshold?: number- Target CLS threshold (default: 0.1)onMeasure?: (value, rating) => void- Called when CLS is measuredonShift?: (entry) => void- Called on each layout shiftonIssue?: (issue) => void- Called when optimization opportunity detectedreportAllChanges?: 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:
{
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:
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:
import { useCLS } from "@page-speed/hooks";
function CLSDashboard() {
const { cls, rating, shiftCount, largestShift, issues } = useCLS({
reportAllChanges: true,
});
return (
<div className="cls-dashboard">
<h3>CLS Monitor</h3>
<div className={`metric ${rating}`}>
<span>CLS Score:</span>
<strong>{cls?.toFixed(4) ?? "—"}</strong>
</div>
<p>Total Shifts: {shiftCount}</p>
{largestShift && (
<div>
<h4>Largest Shift</h4>
<p>Value: {largestShift.value.toFixed(4)}</p>
<p>Elements: {largestShift.sources.map((s) => s.node).join(", ")}</p>
</div>
)}
{issues.length > 0 && (
<div>
<h4>Optimization Opportunities</h4>
<ul>
{issues.map((issue, i) => (
<li key={i}>
<strong>{issue.type}</strong>: {issue.suggestion}
</li>
))}
</ul>
</div>
)}
</div>
);
}Detecting Images Without Dimensions
Automatically find images causing CLS:
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 <button onClick={auditImages}>Audit Images</button>;
}Font Loading CLS Prevention
Monitor and optimize web font shifts:
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: <link rel='preload' href='font.woff2' as='font'>\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 ? (
<div className="warning">
⚠️ {fontIssues.length} font-related layout shifts detected
</div>
) : null;
}Ad Container Space Reservation
Prevent ad-related CLS:
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 (
<div
id={slotId}
style={{
minHeight: `${minHeight}px`,
width: "100%",
backgroundColor: "#f0f0f0",
}}
>
{/* Ad loads here */}
</div>
);
}SPA Navigation CLS Reset
Reset CLS tracking on route changes:
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:
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 (
<div style={{ aspectRatio: "4 / 3", minHeight: "200px" }}>
<div className="skeleton-image" style={{ height: "150px" }} />
<div className="skeleton-text" style={{ height: "20px", marginTop: "10px" }} />
<div className="skeleton-text" style={{ height: "16px", marginTop: "8px" }} />
</div>
);
}
// Calculate recommended aspect ratio
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 }}
/>
<h3>{product.name}</h3>
<p>{product.description}</p>
</div>
);
}Animation Shift Detection
Detect animations causing CLS:
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:
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 (
<div className="cls-optimizer">
<div className={`cls-score cls-${rating}`}>
<span>CLS</span>
<strong>{cls?.toFixed(3) ?? "..."}</strong>
<span className="rating">{rating}</span>
</div>
<button onClick={generateReport}>Generate Report</button>
{issues.length > 0 && (
<details>
<summary>
{issues.length} Optimization{issues.length > 1 ? "s" : ""} Available
</summary>
<ul>
{issues.map((issue, i) => (
<li key={i}>
<code>{issue.type}</code>
<p>{issue.suggestion}</p>
</li>
))}
</ul>
</details>
)}
</div>
);
}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.
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 (
<div>
<p>INP: {inp ? `${inp.toFixed(0)}ms` : "Measuring..."}</p>
<p>Rating: {rating}</p>
{slowestInteraction && (
<div>
<p>Slowest interaction: {slowestInteraction.target}</p>
<p>Input Delay: {slowestInteraction.phases.inputDelay.toFixed(0)}ms</p>
<p>Processing: {slowestInteraction.phases.processingDuration.toFixed(0)}ms</p>
<p>Presentation: {slowestInteraction.phases.presentationDelay.toFixed(0)}ms</p>
</div>
)}
</div>
);
}Options:
threshold?: number- Target INP in milliseconds (default: 200)onMeasure?: (value, rating) => void- Called when INP is measuredonInteraction?: (interaction) => void- Called on each interactiononIssue?: (issue) => void- Called when optimization opportunity detectedreportAllChanges?: 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:
{
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:
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:
import { useINP } from "@page-speed/hooks";
function INPDashboard() {
const {
inp,
rating,
interactionCount,
slowestInteraction,
slowestPhases,
issues,
} = useINP({
reportAllChanges: true,
});
return (
<div className="inp-dashboard">
<h3>INP Monitor</h3>
<div className={`metric ${rating}`}>
<span>INP Score:</span>
<strong>{inp ? `${inp.toFixed(0)}ms` : "—"}</strong>
</div>
<p>Total Interactions: {interactionCount}</p>
{slowestInteraction && slowestPhases && (
<div>
<h4>Slowest Interaction</h4>
<p>Target: {slowestInteraction.target}</p>
<p>Type: {slowestInteraction.type}</p>
<ul>
<li>Input Delay: {slowestPhases.inputDelay.toFixed(0)}ms</li>
<li>Processing: {slowestPhases.processingDuration.toFixed(0)}ms</li>
<li>Presentation: {slowestPhases.presentationDelay.toFixed(0)}ms</li>
</ul>
</div>
)}
{issues.length > 0 && (
<div>
<h4>Optimization Opportunities</h4>
<ul>
{issues.map((issue, i) => (
<li key={i}>
<strong>{issue.type}</strong>: {issue.suggestion}
</li>
))}
</ul>
</div>
)}
</div>
);
}Phase Breakdown Analysis
Identify which phase of interaction is causing slowness:
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:
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 ? (
<div className="warning">
<h4>Scripts Impacting Responsiveness</h4>
<ul>
{topSlowScripts.map((script, i) => (
<li key={i}>
<code>{script.url}</code>
<span>Total: {script.totalDuration.toFixed(0)}ms</span>
<span>({script.occurrences} occurrences)</span>
{script.isThirdParty && <span className="badge">3rd Party</span>}
</li>
))}
</ul>
</div>
) : null;
}SPA Navigation INP Reset
Reset INP tracking on route changes:
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:
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 (
<div className="inp-optimizer">
<div className={`inp-score inp-${rating}`}>
<span>INP</span>
<strong>{inp ? `${inp.toFixed(0)}ms` : "..."}</strong>
<span className="rating">{rating}</span>
</div>
<div className="stats">
<p>Interactions: {interactionCount} ({goodInteractionPercentage.toFixed(0)}% good)</p>
<p>Average: {averageLatency ? `${averageLatency.toFixed(0)}ms` : "—"}</p>
</div>
<button onClick={generateReport}>Generate Report</button>
{issues.length > 0 && (
<details>
<summary>
{issues.length} Optimization{issues.length > 1 ? "s" : ""} Available
</summary>
<ul>
{issues.map((issue, i) => (
<li key={i}>
<code>{issue.type}</code>
<p>{issue.suggestion}</p>
</li>
))}
</ul>
</details>
)}
</div>
);
}🖼️ Media Optimization
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
<picture>element - CLS prevention through explicit dimensions
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 (
<img
ref={ref}
src={src}
loading={loading}
className={isLoaded ? "loaded" : "loading"}
alt="Product"
width={size.width}
height={size.height}
/>
);
}With OptixFlow Integration (Recommended):
Use the <picture> element with responsive srcset for optimal Lighthouse scores:
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 (
<picture>
{/* Modern formats with responsive srcset */}
<source srcSet={srcset.avif} sizes={sizes} type="image/avif" />
<source srcSet={srcset.webp} sizes={sizes} type="image/webp" />
{/* Fallback with pixel-perfect sizing for Lighthouse */}
<img
ref={ref}
src={src}
loading={loading}
className={isLoaded ? "loaded" : "loading"}
alt="Product"
width={size.width}
height={size.height}
decoding="async"
/>
</picture>
);
}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:
{
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 2xBest Practices:
- Use
eager={true}for above-fold images (hero, header) - Use
eager={false}(default) for below-fold images - Use the
<picture>element withsrcsetfor optimal format negotiation - Always include
widthandheightto prevent CLS - Increase
rootMarginto preload before user reaches image - Set
thresholdlower for early loading (0.01) or higher for exact visibility (0.5) - The
sizeproperty updates dynamically via ResizeObserver as images resize - Use
decoding="async"on the<img>for better performance
⚙️ Resource Management
useDeferredMount(options?)
Defers mounting expensive components until after the page is idle, improving Core Web Vitals and initial load performance.
import { useDeferredMount } from "@page-speed/hooks/resources";
function HeavyComponent() {
const shouldRender = useDeferredMount({
delay: 100,
priority: "low",
});
if (!shouldRender) {
return <Skeleton />;
}
return <ExpensiveAnalyticsWidget />;
}
export default function Page() {
return (
<div>
<FastAboveTheFold />
<HeavyComponent /> {/* Won't render until page is idle */}
</div>
);
}Options:
delay?: number- Additional delay after idle (ms, default: 0)priority?: 'low' | 'high'- Use requestIdleCallback (default: 'low')
Returns: boolean - Whether the component should render
How It Works:
priority: 'low'usesrequestIdleCallback(waits for browser idle time)- Adds optional
delayfor extra safety - Falls back to
setTimeouton older browsers - Perfect for non-critical features: analytics, chat widgets, ads
Web.dev Reference: Optimize Interaction to Next Paint (INP)
Examples
Next.js App Router
// 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 (
<html>
<body>{children}</body>
</html>
);
}Remix Loader
// 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 (
<div>
<h1>Products</h1>
<img
ref={ref}
src={src}
alt="Product"
className={isLoaded ? "visible" : "loading"}
/>
<p>LCP: {vitals.lcp}ms</p>
</div>
);
}Analytics Integration
// 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:
// ✅ 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
useWebVitalsonly: ~3.2 KB gzippeduseLCPonly: ~2.8 KB gzippeduseOptimizedImageonly: ~2.1 KB gzippeduseDeferredMountonly: ~1.4 KB gzipped
Metrics & Thresholds
All metrics follow web.dev standards:
Core Web Vitals
| 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 |
Additional Metrics
| 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
Import Patterns
// 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";Type Definitions
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
Metrics not updating
Problem: useWebVitals shows all metrics as null
Solution: Metrics take time to measure. Make sure you:
- Wait a few seconds after page load
- Use
reportAllChanges: truefor development - Check browser console for errors
useWebVitals({
reportAllChanges: true, // Report every update (for dev)
});Images not loading in lazy mode
Problem: useOptimizedImage shows empty src
Solution: Make sure IntersectionObserver is supported:
const { ref, src, isInView } = useOptimizedImage({ src: "/image.jpg" });
// Add fallback src for visibility while loading
return <img ref={ref} src={src || "/placeholder.jpg"} alt="..." />;useDeferredMount never renders
Problem: Component never appears
Solution: Check browser console for errors. useDeferredMount might need a longer delay:
const shouldRender = useDeferredMount({
delay: 500, // Increase delay
priority: "high", // Try high priority
});Contributing
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
Development:
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 buildLicense
BSD 3-Clause License © OpenSite AI
Resources
- 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
Credits
Built with ❤️ by OpenSite AI
Part of the @page-speed ecosystem for performance-first React development.
Have questions? Open an issue or check discussions
