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.json| Transaction | Customer | Status | 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.
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.
useEffect(() => {if (!canAutoLoad || !sentinel) return// ...create and observereturn () => 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.
const loadMore = useCallback(async () => {if (inFlight.current || cursor.current === null) returninFlight.current = truesetStatus('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.
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.
<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 v9? See the v9 version of Infinite Scroll.