# Infinite Scroll

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

A React component for projects built on shadcn/ui. The [interactive docs](https://alexandrejoly.me/components/infinite-scroll) show each example live.

```tsx
import { ArrowCounterClockwiseIcon } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import {
  InfiniteScroll,
  InfiniteScrollContent,
  InfiniteScrollSentinel,
  InfiniteScrollViewport,
} from "@/components/ui/infinite-scroll";
import { useCallback, useState } from "react";

const PAGE_SIZE = 10;
const LAST_PAGE = 3;
const LATENCY_MS = 800;

const pageOf = (page: number) =>
  Array.from({ length: PAGE_SIZE }, (_, index) => page * PAGE_SIZE + index + 1);

const PagedList = () => {
  const [pages, setPages] = useState([pageOf(0)]);
  const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
  const hasNextPage = pages.length <= LAST_PAGE;

  const fetchNextPage = useCallback(() => {
    setIsFetchingNextPage(true);
    setTimeout(() => {
      setPages((loaded) => [...loaded, pageOf(loaded.length)]);
      setIsFetchingNextPage(false);
    }, LATENCY_MS);
  }, []);

  return (
    <InfiniteScroll
      hasNextPage={hasNextPage}
      isFetchingNextPage={isFetchingNextPage}
      onFetchNextPage={fetchNextPage}
    >
      <InfiniteScrollViewport
        aria-label="Items"
        className="scroll-fade-y h-72 w-full max-w-xs overscroll-contain"
        role="region"
        tabIndex={0}
      >
        <InfiniteScrollContent>
          <ul className="flex flex-col">
            {pages.flat().map((id) => (
              <li className="px-3 py-2" key={id}>
                Item {id}
              </li>
            ))}
          </ul>
          <InfiniteScrollSentinel />
          {!hasNextPage && <p className="text-center">No more items</p>}
        </InfiniteScrollContent>
      </InfiniteScrollViewport>
    </InfiniteScroll>
  );
};

export const InfiniteScrollDemo = () => {
  const [run, setRun] = useState(0);

  return (
    <div className="contents [[data-slot=preview]:has(&)]:relative">
      <PagedList key={run} />
      <Tooltip>
        <TooltipTrigger
          delay={0}
          render={
            <Button
              aria-label="Reset"
              className="absolute top-3 right-3"
              onClick={() => setRun((value) => value + 1)}
              size="icon"
              variant="ghost"
            />
          }
        >
          <ArrowCounterClockwiseIcon aria-hidden />
        </TooltipTrigger>
        <TooltipContent>Reset</TooltipContent>
      </Tooltip>
    </div>
  );
};
```

## Installation

With the shadcn CLI:

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

Without the `@alexandrejoly` namespace, pass the registry URL:

```bash
pnpm dlx shadcn@latest add https://alexandrejoly.me/r/infinite-scroll.json
```

Or by hand:

1. Install the dependencies:

   ```bash
   pnpm add @base-ui/react
   ```
2. Add the shadcn components it builds on:

   ```bash
   pnpm dlx shadcn@latest add spinner
   ```
3. Copy the source into `components/ui/infinite-scroll.tsx`, then update the import paths to match your aliases:

   ```tsx
   "use client";

   import { mergeProps } from "@base-ui/react/merge-props";
   import { useRender } from "@base-ui/react/use-render";
   import {
     createContext,
     use,
     useEffect,
     useEffectEvent,
     useMemo,
     useState,
   } from "react";

   import { Spinner } from "@/components/ui/spinner";
   import { cn } from "@/lib/utils";

   interface InfiniteScrollContextValue {
     hasNextPage: boolean;
     isFetchingNextPage: boolean;
     onFetchNextPage: () => void;
     reverse: boolean;
   }

   const InfiniteScrollContext = createContext<InfiniteScrollContextValue | null>(
     null
   );

   function useInfiniteScroll() {
     const context = use(InfiniteScrollContext);
     if (!context) {
       throw new Error(
         "InfiniteScroll compound components must be used within <InfiniteScroll>"
       );
     }
     return context;
   }

   interface InfiniteScrollProps {
     children?: React.ReactNode;
     hasNextPage: boolean;
     isFetchingNextPage: boolean;
     onFetchNextPage: () => void;
     reverse?: boolean;
   }

   function InfiniteScroll({
     onFetchNextPage,
     hasNextPage,
     isFetchingNextPage,
     reverse = false,
     children,
   }: InfiniteScrollProps) {
     const context = useMemo(
       () => ({ hasNextPage, isFetchingNextPage, onFetchNextPage, reverse }),
       [hasNextPage, isFetchingNextPage, onFetchNextPage, reverse]
     );

     return (
       <InfiniteScrollContext value={context}>{children}</InfiniteScrollContext>
     );
   }

   function InfiniteScrollViewport({
     className,
     ...props
   }: React.ComponentProps<"div">) {
     const { reverse } = useInfiniteScroll();

     return (
       <div
         className={cn(
           "overflow-y-auto",
           reverse && "flex flex-col-reverse",
           className
         )}
         data-slot="infinite-scroll-viewport"
         {...props}
       />
     );
   }

   function InfiniteScrollContent({
     className,
     ...props
   }: React.ComponentProps<"div">) {
     const { isFetchingNextPage, reverse } = useInfiniteScroll();

     return (
       <div
         aria-busy={isFetchingNextPage}
         className={cn("flex flex-col gap-4", reverse && "shrink-0", className)}
         data-slot="infinite-scroll-content"
         {...props}
       />
     );
   }

   function InfiniteScrollSentinel({
     render,
     ...props
   }: useRender.ComponentProps<"div">) {
     const { onFetchNextPage, hasNextPage, isFetchingNextPage } =
       useInfiniteScroll();

     const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
     const fetchNextPage = useEffectEvent(onFetchNextPage);

     useEffect(() => {
       if (!(sentinel && hasNextPage) || isFetchingNextPage) {
         return;
       }
       const observer = new IntersectionObserver(
         ([entry]) => {
           if (entry?.isIntersecting) {
             fetchNextPage();
           }
         },
         { threshold: 0 }
       );
       observer.observe(sentinel);
       return () => observer.disconnect();
     }, [sentinel, hasNextPage, isFetchingNextPage]);

     return useRender({
       defaultTagName: "div",
       props: mergeProps<"div">(
         {
           children: isFetchingNextPage && (
             <div className="flex items-center justify-center overflow-clip">
               <Spinner />
             </div>
           ),
           className: "[overflow-anchor:none]",
           ref: setSentinel,
         },
         props
       ),
       render,
       state: {
         hasNextPage,
         isFetchingNextPage,
         slot: "infinite-scroll-sentinel",
       },
     });
   }

   export {
     InfiniteScroll,
     InfiniteScrollContent,
     InfiniteScrollSentinel,
     InfiniteScrollViewport,
   };
   ```

## Usage

```tsx
import {
  InfiniteScroll,
  InfiniteScrollContent,
  InfiniteScrollSentinel,
  InfiniteScrollViewport,
} from "@/components/ui/infinite-scroll";
```

```tsx
<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.

```tsx
import { ArrowCounterClockwiseIcon } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import {
  InfiniteScroll,
  InfiniteScrollContent,
  InfiniteScrollSentinel,
  InfiniteScrollViewport,
} from "@/components/ui/infinite-scroll";
import { useCallback, useState } from "react";

const PAGE_SIZE = 10;
const LAST_PAGE = 3;
const LATENCY_MS = 800;
const TOTAL = PAGE_SIZE * (LAST_PAGE + 1);

const olderPageOf = (page: number) =>
  Array.from(
    { length: PAGE_SIZE },
    (_, index) => TOTAL - (page + 1) * PAGE_SIZE + index + 1
  );

const PagedHistory = () => {
  const [pages, setPages] = useState([olderPageOf(0)]);
  const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
  const hasNextPage = pages.length <= LAST_PAGE;

  const fetchNextPage = useCallback(() => {
    setIsFetchingNextPage(true);
    setTimeout(() => {
      setPages((loaded) => [olderPageOf(loaded.length), ...loaded]);
      setIsFetchingNextPage(false);
    }, LATENCY_MS);
  }, []);

  return (
    <InfiniteScroll
      hasNextPage={hasNextPage}
      isFetchingNextPage={isFetchingNextPage}
      onFetchNextPage={fetchNextPage}
      reverse
    >
      <InfiniteScrollViewport
        aria-label="Messages"
        className="h-72 w-full max-w-xs overscroll-contain"
        role="region"
        tabIndex={0}
      >
        <InfiniteScrollContent>
          {!hasNextPage && <p className="text-center">No older messages</p>}
          <InfiniteScrollSentinel />
          <ul className="flex flex-col">
            {pages.flat().map((id) => (
              <li className="px-3 py-2" key={id}>
                Message {id}
              </li>
            ))}
          </ul>
        </InfiniteScrollContent>
      </InfiniteScrollViewport>
    </InfiniteScroll>
  );
};

export const InfiniteScrollReverse = () => {
  const [run, setRun] = useState(0);

  return (
    <div className="contents [[data-slot=preview]:has(&)]:relative">
      <PagedHistory key={run} />
      <Tooltip>
        <TooltipTrigger
          delay={0}
          render={
            <Button
              aria-label="Reset"
              className="absolute top-3 right-3"
              onClick={() => setRun((value) => value + 1)}
              size="icon"
              variant="ghost"
            />
          }
        >
          <ArrowCounterClockwiseIcon aria-hidden />
        </TooltipTrigger>
        <TooltipContent>Reset</TooltipContent>
      </Tooltip>
    </div>
  );
};
```

## 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](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html)

## API reference

### InfiniteScroll

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `hasNextPage` | `boolean` | — | Whether another page can be fetched. |
| `isFetchingNextPage` | `boolean` | — | Whether a page is being fetched. The content is `aria-busy` and the sentinel shows a spinner and stops observing meanwhile. |
| `onFetchNextPage` | `() => void` | — | Called when the sentinel scrolls into view while a next page exists and none is being fetched. |
| `reverse` | `boolean` | `false` | 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. |

### 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`.

| Prop | Type | Description |
| --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | Replaces the rendered element, for example an `li` inside a list. Its children hold the spinner. |

[All components](https://alexandrejoly.me/components.md)
