Skip to content

InfiniteScroll

MD · 1950B gzip budget

Loads more content near the viewport, and refuses to on constrained connections.

Category
Data display
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/infinite-scroll.json

Copies the source into your project. Pulls in 4 shared registry items: use-network, spinner, cn, visually-hidden.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useEffect, useRef } from "react";
import { useNetwork } from "../lib/use-network";
import { Spinner } from "./spinner";

export interface InfiniteScrollProps {
  onLoadMore: () => void;
  hasMore: boolean;
  loading: boolean;
  loadingLabel?: string;
}

/**
 * Loads more content as a sentinel element nears the viewport — and refuses
 * to fire on a constrained connection, where auto-loading page after page is
 * exactly the kind of unrequested data spend `AdaptiveImage` exists to avoid
 * for images. On Save-Data or 2G, the sentinel simply stops triggering;
 * pairing this with a manual "Load more" fallback button is the caller's
 * responsibility, since only they know what that button should say.
 */
export function InfiniteScroll({ onLoadMore, hasMore, loading, loadingLabel = "Loading more…" }: InfiniteScrollProps) {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const { constrained } = useNetwork();

  useEffect(() => {
    const node = sentinelRef.current;
    if (!node || !hasMore || loading || constrained) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) onLoadMore();
      },
      { rootMargin: "200px" },
    );

    observer.observe(node);
    return () => observer.disconnect();
  }, [hasMore, loading, constrained, onLoadMore]);

  return (
    <div ref={sentinelRef} className="flex justify-center p-4">
      {loading && <Spinner label={loadingLabel} />}
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation