Infinite Scroll

A list that loads more as you scroll, down or up, with a reverse mode held in place by CSS alone

  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
  • Item 6
  • Item 7
  • Item 8
  • Item 9
  • Item 10

Installation

pnpm dlx shadcn@latest add @alexandrejoly/infinite-scroll

Usage

import {  InfiniteScroll,  InfiniteScrollContent,  InfiniteScrollSentinel,  InfiniteScrollViewport,} from "@/components/ui/infinite-scroll";
<InfiniteScroll  hasNextPage={hasNextPage}  isFetchingNextPage={isFetchingNextPage}  onFetchNextPage={() => fetchNextPage()}>  <InfiniteScrollViewport    aria-label="Items"    className="h-96"    role="region"    tabIndex={0}  >    <InfiniteScrollContent>      {items.map((item) => (        <div key={item.id}>{item.title}</div>      ))}      <InfiniteScrollSentinel />    </InfiniteScrollContent>  </InfiniteScrollViewport></InfiniteScroll>

Examples

Reverse

Older pages load at the top. The list opens at the bottom from the first paint, and what you are reading stays put while a page arrives.

  • Message 31
  • Message 32
  • Message 33
  • Message 34
  • Message 35
  • Message 36
  • Message 37
  • Message 38
  • Message 39
  • Message 40

Accessibility

The content is aria-busy while a page is being fetched, so a screen reader waits for the page instead of reading each new item.

Focus stays where it is when a page arrives. In reverse mode, the DOM stays in reading order, oldest first: only the viewport is reversed, so the reading and Tab order match what is on screen.

Give a viewport with no focusable item inside tabIndex={0}, a role such as region and an aria-label, as both examples do. Chrome and Firefox let Tab reach a scroll container on their own, but Safari does not, so without them a keyboard can’t scroll it there. Avoid role="log" on a reversed list: it would announce every older page as it loads.

Meets WCAG 2.2: 1.3.2 Meaningful Sequence (opens in a new tab)

API reference

InfiniteScroll

Shares the pagination state with its parts; it renders no element.

PropTypeDefault
hasNextPage

Whether another page can be fetched.

booleanNone
isFetchingNextPage

Whether a page is being fetched. The content is aria-busy and the sentinel shows a spinner and stops observing meanwhile.

booleanNone
onFetchNextPage

Called when the sentinel scrolls into view while a next page exists and none is being fetched.

() => voidNone
reverse

Loads older pages at the top, as in a chat: the viewport opens at the bottom and keeps its place while pages are added above.

Default: false

booleanfalse

InfiniteScrollViewport

The scroll container. Give it a height. To let the page scroll instead, leave out InfiniteScrollViewport, except in reverse mode, which needs it.

Takes every prop of div.

InfiniteScrollContent

Holds the items and the sentinel, in reading order. It is aria-busy while a page is being fetched.

Takes every prop of div.

InfiniteScrollSentinel

Place it after the last item, or before the first one in reverse mode. It holds the spinner while fetching.

Takes every prop of div.

PropType
render

Replaces the rendered element, for example an li inside a list. Its children hold the spinner.

ReactElement | (props, state) => ReactElement

View as Markdown