InfiniteList
MD · 1950B gzip budgetAuto-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- 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.jsonCopies 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