Skip to content

🚀 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. Core performance tooling utilized by the high-performance OpenSite Octane™ website platform.

License

Notifications You must be signed in to change notification settings

opensite-ai/page-speed-hooks

Repository files navigation

Page Speed React Hooks


⚡ @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.

npm version npm downloads License TypeScript Tree-Shakeable

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/hooks

Basic 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 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 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 measured
  • reportAllChanges?: 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 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:

{
  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 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:

{
  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,
    },
  });

  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')

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 2x

Best Practices:

  • Use eager={true} for above-fold images (hero, header)
  • Use eager={false} (default) for below-fold images
  • Use the <picture> 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 <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:

  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, 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
  • 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:

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:

  1. Wait a few seconds after page load
  2. Use reportAllChanges: true for development
  3. 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 build

License

BSD 3-Clause License © OpenSite AI


Resources


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

About

🚀 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. Core performance tooling utilized by the high-performance OpenSite Octane™ website platform.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published