Search docs

Jump to any table example

Discord—

Shadcn Infinite Scroll Table

A table that loads the next page as you scroll instead of showing pagination controls. Pages come from a cursor-based fetcher, with skeleton rows while loading, an inline Retry when a request fails, and a sticky header.

$ npx shadcn add https://www.shad-table.dev/r/infinite-scroll-table-v9.json
TransactionCustomerStatus
Amount
Date

Showing 0 of 240 — scroll for more

How it works

1.A sentinel row triggers the next page, not a scroll listener

An empty element sits after the last row, and an IntersectionObserver watches it inside the scroll container. When it comes within 160px of the visible area the next page is requested, so there is no scroll handler to throttle and no scrollTop arithmetic to get wrong.

src/components/infinite-scroll/data-table.tsx
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) void loadMore()
},
{ root: scrollRef.current, rootMargin: '0px 0px 160px 0px' },
)
observer.observe(sentinel)

2.The observer is rebuilt after every page

An observer only reports when the sentinel enters or leaves view. If a page is short enough that the sentinel never leaves, nothing would fire again and loading would stall. Depending on rows.length recreates the observer per page, and a new observer reports the sentinel position immediately.

src/components/infinite-scroll/data-table.tsx
useEffect(() => {
if (!canAutoLoad || !sentinel) return
// ...create and observe
return () => observer.disconnect()
}, [canAutoLoad, loadMore, rows.length])

3.A ref guards against duplicate requests

Two observer callbacks can fire before React re-renders, which would fetch the same page twice and duplicate rows. The in-flight flag is a ref rather than state so it updates synchronously, and the cursor lives in a ref for the same reason.

src/components/infinite-scroll/use-infinite-rows.ts
const loadMore = useCallback(async () => {
if (inFlight.current || cursor.current === null) return
inFlight.current = true
setStatus('loading')
// ...fetch, append rows, advance the cursor
}, [fetchPage])

4.Errors pause auto-loading until the user retries

Auto-loading is only enabled while the status is idle. After a failed request the sentinel is still in view, so without that check the observer would hammer the failing endpoint. Instead an inline Retry button calls loadMore again. The demo fails once at row 100 so you can see it.

src/components/infinite-scroll/data-table.tsx
const canAutoLoad = hasMore && status === 'idle'

5.A sticky header needs the table wrapper to stop scrolling

The shadcn Table wraps the table in a div with overflow-x-auto, which silently becomes the sticky header's scroll parent and stops it sticking. The outer container owns both scroll axes instead, and the arbitrary variant switches the wrapper back to overflow-visible.

src/components/infinite-scroll/data-table.tsx
<div className="max-h-96 overflow-auto [&_[data-slot=table-container]]:overflow-visible">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">

Using TanStack Table v8? See the v8 Infinite Scroll.