{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "infinite-scroll",
  "title": "Infinite Scroll",
  "description": "The footer of a list that keeps going: an invisible sentinel that loads the next page as it scrolls into view, plus a Load more button that always does the same job by hand. Reach for it on a feed or timeline, search results, a notification or activity list, a product or photo grid, a comment thread, chat history, an audit log, or any 'show more' at the end of a long table. Common asks it answers: \"infinite scroll\", \"infinite scrolling react\", \"load more on scroll\", \"load more button\", \"endless scroll\", \"auto load next page\", \"IntersectionObserver load more\", \"react-infinite-scroll-component alternative\", \"scroll pagination\", \"fetch next page when the sentinel is visible\", \"lazy load a long list\". shadcn/ui's pagination is numbered page links and nothing else — it renders no rows and loads nothing — so progressive loading gets hand-rolled every time, and the same three things break. Here the page footer stays reachable, because automatic loading yields to the button after autoLoadLimit pages (default 3, and a press grants another run) instead of running the page away from whatever is below the list. Each page is announced through a polite live region (\"20 more items loaded. 60 in total.\") rather than rows appearing in silence, and the button uses aria-disabled instead of disabled so pressing it never drops focus out of the list. And a failed page stops the sentinel and offers Retry instead of hammering a broken endpoint in a loop. Return a promise from onLoadMore and the duplicate-fire guard is exact; a loader that only bumps a page number is held until the list actually changes, so it asks once instead of firing a burst. A first page shorter than the viewport keeps loading until the viewport is full — the usual bug there is a sentinel that never leaves the screen, so no second intersection event ever comes and the list stops loading forever. Controlled: pass hasMore, itemCount and onLoadMore, and render it directly after your rows — it draws no list of its own, so it goes at the end of a ul or a grid unchanged. A table is the one place it needs placing by hand: this renders a div, and the HTML parser hoists a div written inside tbody out of the table entirely and drops it above the table, breaking the layout and hydration with it — so put it after the closing table tag, or inside a td with colSpan in a footer row. Optional loading for react-query or SWR, error for your own failure state, root for a list that scrolls inside a box rather than the page, rootMargin (default 200px) to prefetch early, auto={false} for button-only, and labels to reword or translate every string. Styled with shadcn tokens so it follows light and dark themes; lucide-react is the only dependency, with no Radix and no scroll library.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/infinite-scroll.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** Every string a user reads or hears. Override to translate or to reword. */\nexport interface InfiniteScrollLabels {\n  loadMore: string\n  /** Shown in the button while a page is in flight, and announced through the live region. */\n  loading: string\n  /** Announced after each page arrives — the only feedback a screen-reader user gets. */\n  loaded: (added: number, total: number) => string\n  /** Shown once `hasMore` turns false. */\n  end: string\n  error: string\n  retry: string\n}\n\nconst defaultLabels: InfiniteScrollLabels = {\n  loadMore: \"Load more\",\n  loading: \"Loading more…\",\n  loaded: (added, total) =>\n    `${added} more ${added === 1 ? \"item\" : \"items\"} loaded. ${total} in total.`,\n  end: \"You're all caught up.\",\n  error: \"Couldn't load more.\",\n  retry: \"Retry\",\n}\n\nexport interface InfiniteScrollProps {\n  /**\n   * Load the next page. Return the promise and the sentinel stays quiet until it settles — that\n   * is the whole duplicate-fire guard. A rejection is caught here and turns into the error row.\n   */\n  onLoadMore: () => void | Promise<unknown>\n  /** False when the last page has arrived: the sentinel stops firing and `end` is shown. */\n  hasMore: boolean\n  /** How many rows are currently rendered. Drives the \"N more loaded\" announcement. */\n  itemCount: number\n  /** Busy flag for loaders that don't return a promise (react-query, SWR). ORed with the internal one. */\n  loading?: boolean\n  /** Force the error row. `true` uses `labels.error`; any other node is shown as the message. */\n  error?: React.ReactNode\n  /** Load automatically when the sentinel scrolls into view. False = button only. */\n  auto?: boolean\n  /**\n   * How many pages may load automatically before the button has to be pressed again. This is what\n   * keeps the page footer reachable and stops a slow connection from swallowing twenty pages;\n   * pressing the button grants another run. Infinity restores the usual never-ending behaviour.\n   */\n  autoLoadLimit?: number\n  /** The scrolling ancestor, when the list scrolls inside a box rather than the page. */\n  root?: React.RefObject<Element | null>\n  /** How early to reach for the next page, as an IntersectionObserver margin. */\n  rootMargin?: string\n  labels?: Partial<InfiniteScrollLabels>\n  className?: string\n}\n\ninterface AutoLoadState {\n  /** The sentinel is in view (or within `rootMargin` of it). */\n  visible: boolean\n  auto: boolean\n  hasMore: boolean\n  busy: boolean\n  errored: boolean\n  /** A load whose end can't be observed is outstanding — see `pausedAt` in the component. */\n  paused: boolean\n  autoLoads: number\n  autoLoadLimit: number\n}\n\n/**\n * Whether the sentinel should reach for another page right now. Kept as one pure predicate\n * because every one of these terms is a way the usual hand-rolled version misbehaves: dropping\n * `busy` double-fetches, dropping `errored` retries a broken endpoint forever, and dropping the\n * limit runs the list away from the page footer.\n */\nfunction shouldAutoLoad(s: AutoLoadState): boolean {\n  if (!s.visible || !s.auto || !s.hasMore) return false\n  if (s.busy || s.errored || s.paused) return false\n  return s.autoLoads < s.autoLoadLimit\n}\n\n/**\n * The footer of a paginated list: an invisible sentinel that loads the next page as it scrolls\n * into view, a button that always does the same job by hand, and a live region that says what\n * arrived. Render it directly after the rows — it draws no list of its own, so it sits happily at\n * the end of a `<ul>` or a grid.\n *\n * A table is the one exception, and not because of anything here: this renders a `<div>`, and the\n * HTML parser moves a `<div>` written inside `<tbody>` out of the table altogether, landing it\n * *above* the table and taking the hydration pass with it. Put it after the `</table>`, or inside\n * a `<td colSpan>` in a footer row.\n *\n * Three things a hand-rolled IntersectionObserver almost always gets wrong are handled here:\n * the page footer stays reachable (automatic loading yields to the button after `autoLoadLimit`\n * pages), rows that appear are announced instead of arriving in silence, and a page that fails\n * stops the automatic loading rather than hammering a broken endpoint in a loop.\n */\nexport function InfiniteScroll({\n  onLoadMore,\n  hasMore,\n  itemCount,\n  loading = false,\n  error,\n  auto = true,\n  autoLoadLimit = 3,\n  root,\n  rootMargin = \"200px\",\n  labels: labelOverrides,\n  className,\n}: InfiniteScrollProps) {\n  const labels = { ...defaultLabels, ...labelOverrides }\n\n  const sentinelRef = React.useRef<HTMLDivElement>(null)\n  const onLoadMoreRef = React.useRef(onLoadMore)\n  onLoadMoreRef.current = onLoadMore\n\n  const [visible, setVisible] = React.useState(false)\n  const [pending, setPending] = React.useState(false)\n  const [failed, setFailed] = React.useState(false)\n  const [pausedAt, setPausedAt] = React.useState<{ itemCount: number; hasMore: boolean } | null>(null)\n  const [autoLoads, setAutoLoads] = React.useState(0)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  // Both mirror state for guards that have to hold *within* a tick, before React has re-rendered:\n  // `busyRef` for a page in flight, `pausedRef` for one already asked for. Without them a\n  // double-invoked effect (StrictMode in development) would ask for the same page twice.\n  const busyRef = React.useRef(false)\n  const pausedRef = React.useRef<{ itemCount: number; hasMore: boolean } | null>(null)\n  const busy = pending || loading\n  const errored = failed || Boolean(error)\n  const errorMessage = error == null || typeof error === \"boolean\" ? labels.error : error\n\n  // Derived rather than a second effect that clears a `paused` flag: the effect that pauses and\n  // the effect that resumes would land in the same batch on mount, and the resume would win.\n  const paused = pausedAt !== null && pausedAt.itemCount === itemCount && pausedAt.hasMore === hasMore\n\n  const load = React.useCallback((snapshot: { itemCount: number; hasMore: boolean }) => {\n    if (busyRef.current) return\n    const held = pausedRef.current\n    if (held && held.itemCount === snapshot.itemCount && held.hasMore === snapshot.hasMore) return\n\n    const settle = (ok: boolean) => {\n      busyRef.current = false\n      setPending(false)\n      if (!ok) setFailed(true)\n    }\n\n    let result: void | Promise<unknown>\n    try {\n      result = onLoadMoreRef.current()\n    } catch {\n      setFailed(true)\n      return\n    }\n    setFailed(false)\n\n    if (typeof (result as Promise<unknown> | undefined)?.then === \"function\") {\n      busyRef.current = true\n      pausedRef.current = null\n      setPending(true)\n      setPausedAt(null)\n      Promise.resolve(result).then(\n        () => settle(true),\n        () => settle(false)\n      )\n      return\n    }\n\n    // Nothing was returned, so there is no way to know when this page lands: a loader that only\n    // bumps a page number and lets a data hook fetch in the background looks finished the instant\n    // it returns. Rather than show a spinner that might never stop, hold the *automatic* loading\n    // until the list itself changes. The button stays live throughout, so the worst case here is\n    // one press instead of a burst of duplicate pages.\n    pausedRef.current = snapshot\n    setPausedAt(snapshot)\n  }, [])\n\n  // The sentinel is rendered even after the last page, so this observer outlives `hasMore`\n  // flipping (a new filter can put the list back into having more) and never has to be rebuilt\n  // around an element that comes and goes.\n  React.useEffect(() => {\n    const el = sentinelRef.current\n    if (!el) return\n    const observer = new IntersectionObserver(\n      (entries) => setVisible(entries[entries.length - 1].isIntersecting),\n      { root: root?.current ?? null, rootMargin }\n    )\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [root, rootMargin])\n\n  // Deciding from state rather than from inside the observer callback is what makes a short first\n  // page work: when the list is shorter than the viewport the sentinel never leaves it, so no\n  // second intersection event ever comes — but this effect re-runs the moment the load settles and\n  // fires again while the sentinel is still in view, until the viewport is full or the limit hits.\n  React.useEffect(() => {\n    if (!shouldAutoLoad({ visible, auto, hasMore, busy, errored, paused, autoLoads, autoLoadLimit })) return\n    setAutoLoads((n) => n + 1)\n    load({ itemCount, hasMore })\n  }, [visible, auto, hasMore, busy, errored, paused, autoLoads, autoLoadLimit, itemCount, load])\n\n  const previousCount = React.useRef(itemCount)\n  React.useEffect(() => {\n    const previous = previousCount.current\n    previousCount.current = itemCount\n    if (itemCount > previous) {\n      setAnnouncement(labels.loaded(itemCount - previous, itemCount))\n    } else if (itemCount < previous) {\n      // The list was replaced rather than extended (a new query, a cleared filter), so the run of\n      // automatic loads starts over instead of the fresh list being stuck behind the old count.\n      setAutoLoads(0)\n    }\n    // labels is rebuilt every render; the announcement is keyed off the count alone.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [itemCount])\n\n  function handleClick() {\n    if (busy) return\n    // An explicit press is a fresh mandate: it re-asks for the page the sentinel is holding,\n    // clears a failure and re-arms automatic loading.\n    pausedRef.current = null\n    setAutoLoads(0)\n    load({ itemCount, hasMore })\n  }\n\n  return (\n    <div className={cn(\"flex w-full flex-col items-center gap-2 py-4\", className)}>\n      {/* Not aria-hidden: it holds no content, and hiding it would only add a node for assistive\n          tech to skip. Kept a pixel tall so it can actually intersect. */}\n      <div ref={sentinelRef} className=\"h-px w-full\" />\n\n      <div role=\"status\" aria-live=\"polite\" className=\"text-sm\">\n        {errored ? (\n          <span className=\"text-destructive\">{errorMessage}</span>\n        ) : !hasMore && itemCount > 0 ? (\n          <span className=\"text-muted-foreground\">{labels.end}</span>\n        ) : null}\n        {/* The button carries the visible busy state; this is how it reaches a screen reader. */}\n        <span className=\"sr-only\">{busy ? labels.loading : announcement}</span>\n      </div>\n\n      {hasMore ? (\n        <button\n          type=\"button\"\n          onClick={handleClick}\n          // aria-disabled rather than disabled: a disabled button loses focus, which would drop\n          // the user out of the list every time they pressed this one.\n          aria-disabled={busy}\n          aria-busy={busy}\n          className={cn(\n            \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-background px-4 text-sm font-medium\",\n            \"hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            busy && \"pointer-events-none opacity-50\"\n          )}\n        >\n          {busy ? <Loader2 className=\"h-4 w-4 animate-spin\" aria-hidden=\"true\" /> : null}\n          {busy ? labels.loading : errored ? labels.retry : labels.loadMore}\n        </button>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}