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
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
npm install @page-speed/hooks
# or
pnpm add @page-speed/hooks
# or
yarn add @page-speed/hooksimport { 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>
);
}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:
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
Lazy loads images below the fold with IntersectionObserver, automatically deferring loading until the element is visible.
import { useOptimizedImage } from "@page-speed/hooks/media";
function ProductImage() {
const { ref, src, isLoaded, loading } = 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={800}
height={600}
/>
);
}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')
Returns:
{
ref: (node) => void // Attach to img element
src: string // Image source (empty until loaded)
isLoaded: boolean // Image has loaded
isInView: boolean // Element is in viewport
loading: 'lazy' | 'eager' // Loading strategy used
}Best Practices:
- Use
eager={true}for above-fold images (hero, header) - Use
eager={false}(default) for below-fold images - Increase
rootMarginto preload before user reaches image - Set
thresholdlower for early loading (0.01) or higher for exact visibility (0.5)
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)
// 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>
);
}// 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>
);
}// 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;
}@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
All metrics follow web.dev standards:
| 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 |
| 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 |
- ✅ Chrome/Edge 90+
- ✅ Firefox 88+
- ✅ Safari 14+
- ✅ Mobile browsers with Web Vitals support
Note: Gracefully degrades on older browsers with polyfills available.
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)
// Full library
import {
useWebVitals,
useLCP,
useOptimizedImage,
useDeferredMount,
} from "@page-speed/hooks";
// Web Vitals only
import { useWebVitals, useLCP } from "@page-speed/hooks/web-vitals";
// Media only
import { useOptimizedImage } from "@page-speed/hooks/media";
// Resources only
import { useDeferredMount } from "@page-speed/hooks/resources";import type {
Metric,
WebVitalsOptions,
WebVitalsState,
LCPOptions,
LCPState,
UseOptimizedImageOptions,
UseOptimizedImageState,
UseDeferredMountOptions,
} from "@page-speed/hooks";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)
});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="..." />;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
});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 buildBSD 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
