Skip to content

InfiniteList

MD · 1950B gzip budget

Auto-loading list that fetches more items near the bottom.

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

Preview

Live fixture
Preview locale
  • 1
  • 2
  • 3

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-list.json

Copies the source into your project.

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 React, { useCallback, useEffect, useRef, useState } from "react";

export interface InfiniteListProps<T> {
  items: T[];
  loadMore: () => Promise<void>;
  hasMore: boolean;
  loading?: boolean;
  renderItem: (item: T, index: number) => React.ReactNode;
  loadingIndicator?: React.ReactNode;
  endMessage?: React.ReactNode;
  className?: string;
  listClassName?: string;
  ariaLabel?: string;
}

export function InfiniteList<T>({
  items,
  loadMore,
  hasMore,
  loading = false,
  renderItem,
  loadingIndicator,
  endMessage,
  className = "",
  listClassName = "",
  ariaLabel,
}: InfiniteListProps<T>) {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [loadingMore, setLoadingMore] = useState(false);

  const handleLoadMore = useCallback(async () => {
    if (loading || loadingMore || !hasMore) return;
    setLoadingMore(true);
    try {
      await loadMore();
    } finally {
      setLoadingMore(false);
    }
  }, [loadMore, loading, loadingMore, hasMore]);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

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

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [handleLoadMore]);

  const isLoading = loading || loadingMore;

  return (
    <div className={className} aria-label={ariaLabel}>
      <ul
        className={listClassName}
        role="list"
        style={{ listStyle: "none", padding: 0, margin: 0 }}
      >
        {items.map((item, index) => (
          <li key={index}>{renderItem(item, index)}</li>
        ))}
      </ul>

      <div
        ref={sentinelRef}
        aria-hidden="true"
        style={{ height: 1 }}
      />

      {isLoading && (
        <div
          role="status"
          aria-live="polite"
          style={{
            display: "flex",
            justifyContent: "center",
            paddingBlock: "1.5rem",
          }}
        >
          {loadingIndicator ?? (
            <span style={{ color: "var(--color-muted, #6b7280)" }}>
              Loading more…
            </span>
          )}
        </div>
      )}

      {!hasMore && !isLoading && endMessage != null && (
        <div
          role="status"
          style={{
            textAlign: "center",
            paddingBlock: "1.5rem",
            color: "var(--color-muted, #6b7280)",
          }}
        >
          {endMessage}
        </div>
      )}
    </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