Infinite scroll for React. Zero runtime dependencies, IntersectionObserver-based, TypeScript-first. ~4 kB gzipped.
Works with window scroll, fixed-height containers, and custom scrollable parents. Pull-to-refresh and inverse (chat) scroll included. React 17, 18, and 19 compatible.
npm install react-infinite-scroll-component
# or
yarn add react-infinite-scroll-component
# or
pnpm add react-infinite-scroll-component| API | When to use |
|---|---|
InfiniteScroll component |
Most cases, handles loader, endMessage, pull-to-refresh, inverse scroll UI |
useInfiniteScroll hook |
Custom UI, you own the markup, the hook manages the observer |
import { useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
type Item = { id: number; name: string };
function Feed() {
const [items, setItems] = useState<Item[]>(initialItems);
const [hasMore, setHasMore] = useState(true);
const fetchMore = async () => {
const next = await api.getItems({ offset: items.length });
if (next.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...next]);
};
return (
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
endMessage={<p style={{ textAlign: 'center' }}>All items loaded.</p>}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
);
}<div id="scrollableDiv" style={{ height: 400, overflow: 'auto' }}>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
scrollableTarget="scrollableDiv"
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
</div>Pass a ref value directly instead of a string id:
const containerRef = useRef<HTMLDivElement>(null);
<div ref={containerRef} style={{ height: 400, overflow: 'auto' }}>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
scrollableTarget={containerRef.current}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
</div>;<div
id="chatBox"
style={{
height: 500,
overflow: 'auto',
display: 'flex',
flexDirection: 'column-reverse',
}}
>
<InfiniteScroll
dataLength={messages.length}
next={loadOlderMessages}
hasMore={hasMore}
loader={<p>Loading older messages...</p>}
inverse={true}
scrollableTarget="chatBox"
style={{ display: 'flex', flexDirection: 'column-reverse' }}
>
{messages.map((msg) => (
<div key={msg.id}>{msg.text}</div>
))}
</InfiniteScroll>
</div><InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
pullDownToRefresh
pullDownToRefreshThreshold={50}
refreshFunction={refreshList}
pullDownToRefreshContent={
<h3 style={{ textAlign: 'center' }}>↓ Pull down to refresh</h3>
}
releaseToRefreshContent={
<h3 style={{ textAlign: 'center' }}>↑ Release to refresh</h3>
}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>For when you need full control over your markup. Place the sentinelRef div at the end of your list, the hook fires next() when it enters the viewport.
import { useState } from 'react';
import { useInfiniteScroll } from 'react-infinite-scroll-component';
type Item = { id: number; name: string };
function CustomFeed() {
const [items, setItems] = useState<Item[]>(initialItems);
const [hasMore, setHasMore] = useState(true);
const { sentinelRef, isLoading } = useInfiniteScroll({
next: async () => {
const more = await api.getItems({ offset: items.length });
if (more.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...more]);
},
hasMore,
dataLength: items.length,
});
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
<li ref={sentinelRef} aria-hidden="true" />
{isLoading && <li>Loading...</li>}
{!hasMore && <li>All items loaded.</li>}
</ul>
);
}InfiniteScroll is a client component. Fetch initial data in a Server Component, pass it down.
// app/feed/page.tsx, Server Component
import { FeedClient } from './feed-client';
import { db } from '@/lib/db';
export default async function FeedPage() {
const initialItems = await db.items.findMany({
take: 20,
orderBy: { id: 'desc' },
});
return <FeedClient initialItems={initialItems} />;
}// app/feed/feed-client.tsx, Client Component
'use client';
import { useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
type Item = { id: string; title: string };
export function FeedClient({ initialItems }: { initialItems: Item[] }) {
const [items, setItems] = useState(initialItems);
const [hasMore, setHasMore] = useState(true);
const fetchMore = async () => {
const res = await fetch(`/api/items?cursor=${items[items.length - 1].id}`);
const next: Item[] = await res.json();
if (next.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...next]);
};
return (
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
endMessage={<p>You have seen everything.</p>}
>
{items.map((item) => (
<article key={item.id}>{item.title}</article>
))}
</InfiniteScroll>
);
}import { useInfiniteQuery } from '@tanstack/react-query';
import InfiniteScroll from 'react-infinite-scroll-component';
function PostFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, pages) =>
lastPage.length === 20 ? pages.length : undefined,
});
const posts = data?.pages.flat() ?? [];
return (
<InfiniteScroll
dataLength={posts.length}
next={fetchNextPage}
hasMore={!!hasNextPage}
loader={isFetchingNextPage ? <p>Loading...</p> : null}
endMessage={<p>All posts loaded.</p>}
>
{posts.map((post) => (
<article key={post.id}>{post.title}</article>
))}
</InfiniteScroll>
);
}import useSWRInfinite from 'swr/infinite';
import InfiniteScroll from 'react-infinite-scroll-component';
const PAGE_SIZE = 20;
function PostList() {
const { data, size, setSize } = useSWRInfinite(
(index) => `/api/posts?page=${index}&limit=${PAGE_SIZE}`,
fetcher
);
const posts = data ? data.flat() : [];
const hasMore = data ? data[data.length - 1].length === PAGE_SIZE : true;
return (
<InfiniteScroll
dataLength={posts.length}
next={() => setSize(size + 1)}
hasMore={hasMore}
loader={<p>Loading...</p>}
>
{posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</InfiniteScroll>
);
}| Mode | How to use | Use case |
|---|---|---|
| Window scroll | Omit height and scrollableTarget |
Social feeds, blogs, product listings |
| Fixed-height container | Pass height prop |
Embedded lists, sidebars |
| Custom scrollable parent | Pass scrollableTarget (element or id) |
Existing overflow containers |
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
dataLength |
number |
yes | - | Current count of rendered items. The component resets its load guard each time this value changes, which allows next() to fire again on the next scroll. |
next |
() => void |
yes | - | Called once when the sentinel enters the viewport. Append new items to your list state inside this callback; do not replace the existing items. |
hasMore |
boolean |
yes | - | When false, the observer is disconnected and next() will not be called again. Set it to false when your data source has no more pages. |
loader |
ReactNode |
yes | - | Rendered below the list while the next page is loading. Displayed between the last item and the bottom sentinel. |
endMessage |
ReactNode |
no | - | Rendered below the list when hasMore is false. Use it for an "all caught up" or "no more items" message. |
height |
number | string |
no | - | Creates a fixed-height scroll container wrapping the list. Accepts a pixel number or any CSS length string. Omit this prop to scroll the window instead. |
scrollableTarget |
HTMLElement | string | null |
no | - | The scrollable ancestor that already provides overflow scrollbars. Pass the element's id string or a direct HTMLElement reference. Required when the scroll container is neither the window nor the height wrapper. |
scrollThreshold |
number | string |
no | 0.8 |
How close to the bottom the user must scroll before next() is called. A fraction like 0.8 means 80% scrolled; a string like "200px" means within 200 px of the bottom edge. |
inverse |
boolean |
no | false |
Reverse scroll direction for chat or messaging UIs. The sentinel moves to the top of the list. Use together with flexDirection: column-reverse on the scroll container. |
pullDownToRefresh |
boolean |
no | false |
Enable pull-to-refresh gesture on touch and mouse. Requires refreshFunction to also be set. |
refreshFunction |
() => void |
no | - | Called once when the user pulls down past pullDownToRefreshThreshold pixels and releases. Only active when pullDownToRefresh is true. |
pullDownToRefreshThreshold |
number |
no | 100 |
How many pixels the user must pull down before refreshFunction is triggered on release. |
pullDownToRefreshContent |
ReactNode |
no | - | Content shown inside the pull-to-refresh area while the user is pulling but has not yet reached the threshold. |
releaseToRefreshContent |
ReactNode |
no | - | Content shown inside the pull-to-refresh area once the threshold is passed and the user can release to trigger a refresh. |
onScroll |
(e: UIEvent) => void |
no | - | Callback fired on every scroll event on the container. Receives the native UIEvent. Useful for syncing UI state with scroll position. |
className |
string |
no | '' |
CSS class name applied to the inner scroll container div. |
style |
CSSProperties |
no | - | Inline style object applied to the inner scroll container div. Merged with the component's default layout styles. |
hasChildren |
boolean |
no | - | Set to true when children is a single element or a fragment rather than an array. Helps the component detect whether visible content exists to determine scroll state. |
initialScrollY |
number |
no | - | Scrolls the window to this Y offset on mount. Useful for restoring a user's scroll position when navigating back to a page. |
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
dataLength |
number |
yes | - | Current count of rendered items. The hook resets its load guard whenever this value changes, allowing next() to fire again on the next intersection. |
next |
() => void |
yes | - | Called once when the sentinel enters the viewport. Append new items to your list state inside this callback; do not replace the existing items. |
hasMore |
boolean |
yes | - | When false, the IntersectionObserver is disconnected and next() will not be called again. Set it to false when your data source has no more pages. |
scrollThreshold |
number | string |
no | 0.8 |
How close to the edge the sentinel must be before next() fires. A fraction like 0.8 means 80% scrolled; a string like "200px" means within 200 px of the edge. |
scrollableTarget |
HTMLElement | string | null |
no | - | The scrollable ancestor to use as the observer root. Pass a DOM id string or an HTMLElement reference. When omitted, the observer uses the browser viewport. |
inverse |
boolean |
no | false |
When true, the rootMargin is applied to the top edge instead of the bottom. Place the sentinel at the top of your list and use flexDirection: column-reverse for chat UIs. |
Returns { sentinelRef, isLoading }.
- IntersectionObserver-based triggering,
next()fires once when the sentinel enters the viewport, not on every scroll tick. No missed triggers, better performance. useInfiniteScrollhook, low-level hook for building fully custom UIs.- Zero runtime dependencies,
throttle-debounceremoved. scrollableTargetacceptsHTMLElement, pass a ref value directly, not just a string id.- Function component rewrite, same public API, no migration needed.
- React 17, 18, 19 compatible.
- infinite scroll (never ending), window scroll
- infinite scroll till 500 elements, window scroll
- infinite scroll in an element (height 400px)
- infinite scroll with
scrollableTarget
Thanks goes to these wonderful people (emoji key):
This project follows the all-contributors specification. Contributions of any kind are welcome!