Display

Infinite Scroll

A sentinel-driven feed with four first-class data states — a failed page keeps every row already loaded, the in-flight lock is a ref so no page is ever fetched twice, and the manual trigger plus a role=feed keyboard walk make it reachable without a mouse.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ArrowDown, CircleCheck, Inbox, Loader2, RotateCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import type { InfiniteScrollItem, InfiniteScrollStatus } from "./infinite-scroll.contract"

export interface InfiniteScrollLabels {
  /** Trigger label while another page is available. */
  loadMore: string
  /** Trigger label while a page is in flight. */
  loading: string
  /** Trigger label after a failed attempt — the SAME page is retried. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/infinite-scroll.json

Prompt

Build a React + TypeScript + Tailwind "InfiniteScroll" component with zod and
lucide-react. No paging library: one IntersectionObserver, one ref-based lock.

Contract
- A zod schema (`infiniteScrollSchema` in a sibling contract file) is the single
  source of truth for the envelope a data layer hands over: { status: "loading" |
  "empty" | "error" | "ready", items: Item[], hasMore: boolean, error: string |
  null, total: number | null }. `Item` is { id, title, description?, meta?,
  avatarUrl? } — `id` must be stable across refetches and `title` is also the
  row's accessible name.
- ONE status enum covers the first page and every appended page, because the
  branch a reader sees is decided by the status TOGETHER with how much is already
  loaded: loading + nothing → first-page skeleton; loading + rows → skeletons
  appended under the rows; error + nothing → the whole panel failed; error + rows
  → an inline row at the end with everything still on screen; empty → no results;
  ready → rows plus either the trigger or the end-of-list marker.
- `items` is CUMULATIVE — everything loaded so far, not the latest page. The
  component never keeps its own copy, so a consumer that replaces instead of
  appending sees exactly what it asked for.
- `total` is the backend's count when it reports one and `null` otherwise; the
  component must not derive it from `hasMore`.
- Props: status; items; hasMore; onLoadMore(); error? (null); total? (null);
  autoLoad? (true); rootMargin? ("200px"); autoLoadBurst? (3); skeletonRows? (3);
  maxHeight?; renderItem?(item, index); emptyState?; label? ("Feed"); labels?
  (loadMore / loading / retry / end / empty / errorFirst / errorMore);
  formatStatus?;
  className plus the rest of the div props, ref forwarded to the root.
- `skeletonRows` and `autoLoadBurst` are clamped (>= 1 and >= 0), so a NaN or a 0
  from a config file can never render an empty skeleton or spin a budget forever.

Behavior
- ONE FETCH PER PAGE. The in-flight lock is a ref that is read and written
  synchronously inside the same handler: two clicks dispatched in one task — or a
  click landing in the same task as the sentinel callback — both run before React
  re-renders, so a state-only flag would still read `false` on the second and
  fetch the same page twice. The lock reopens only when the parent's data
  observably moves (status / item count / hasMore / error), which is also the
  contract to document: a handler that changes nothing leaves the feed armed but
  idle, on purpose, instead of retrying into the void.
- The sentinel is a 1px inert node at the very bottom of the content, watched by
  an IntersectionObserver with a `rootMargin` so the next page starts BEFORE the
  reader reaches the end. When `maxHeight` is set the component becomes its own
  scroll container and that box is the observer's `root`; otherwise `root` is the
  viewport. Build the observer in an effect (not in the ref callback): child refs
  attach before parent refs, so the scroll box would still be null. Keep both the
  sentinel node and the scroll box in STATE so the effect re-runs when either
  changes, and disconnect the observer in the cleanup — on unmount and on every
  dependency change.
- AUTO-LOAD IS BUDGETED. Pages shorter than the viewport leave the sentinel on
  screen after every append, which would chain-load the whole dataset in one go.
  `autoLoadBurst` caps how many pages may load while the sentinel stays
  continuously in view; the budget refills when the sentinel scrolls out of view
  (the reader moved) or when the trigger is pressed. `autoLoadBurst: 0` turns
  auto-loading off entirely.
- AN ERROR STOPS THE SENTINEL. While `error` is non-null the sentinel is not
  rendered at all, so auto-loading cannot retry: an invisible retry loop is
  exactly how a failing endpoint gets hammered by a sentinel that never scrolls
  away. Only a human press retries, and a manual press is allowed even when
  `hasMore` is false — a page that errored never told us what was behind it.
- A FAILED PAGE NEVER COSTS THE ROWS ALREADY LOADED. `status: "error"` with items
  present renders an inline row after the last item, with the message and a Try
  again that re-fetches the SAME page. It is deliberately not `role="alert"`: the
  reader is mid-feed, the rows above are still readable, and the polite live
  region carries the news instead of interrupting. The first-page failure is the
  opposite case — nothing to preserve, so that one IS `role="alert"`.
- THE MANUAL TRIGGER IS ALWAYS RENDERED, even with `autoLoad` on. A sentinel only
  fires on scroll, which makes it unreachable by keyboard and unusable for anyone
  who navigates by landmarks. It is never natively `disabled` while a page is in
  flight — the browser blurs a control the instant it becomes disabled, dropping
  focus to <body> mid-action; use `aria-disabled` plus a guard in the handler.
- FOCUS SURVIVES THE TRIGGER'S DISAPPEARANCE. The trigger is replaced (Try again
  → Load more) and eventually removed (the feed ends). Once the reader has
  pressed it, any change of trigger kind that left focus on <body> hands focus to
  whatever now stands in that place — the new trigger, or the end-of-list marker
  (a `tabIndex={-1}` target with its own focus ring).
- APPENDS ARE ANNOUNCED, POLITELY. One persistent `role="status"` sr-only region,
  always in the same position in the tree so React keeps the same DOM node across
  every envelope: a live region that MOUNTS with its text already in it is not
  announced. It speaks only on a real change — an append ("Loaded 6 more. 12 of
  24 items loaded."), a fresh failure, or the end of the list — never on an
  unrelated re-render.
- SCROLL POSITION IS PRESERVED across an append by two mechanics, not by
  measuring and restoring: rows are keyed by `item.id` so no already-rendered
  node is re-created, and the footer (skeletons, trigger, marker, sentinel) is
  marked `overflow-anchor: none` so the browser cannot pick it as the scroll
  anchor and slide a freshly appended page past the reader while pinning the
  button under the cursor.

Rendering & styling
- ARIA: the row container is `role="feed"` with `aria-label` and `aria-busy`
  while a page is in flight. Every row is an `<article>` with `aria-posinset`,
  `aria-setsize` (`total ?? -1`, where -1 is the ARIA value for "unknown") and
  `aria-label={item.title}`. The trigger, the skeletons, the error row and the
  end marker live OUTSIDE the feed element — a `feed` may own articles only.
  Skeletons and the sentinel are `aria-hidden`, and the avatar is decorative
  (`alt=""`) because the title already names the row.
- KEYBOARD MAP: the feed is ONE tab stop (roving tabindex, never one stop per
  row). Page Down moves to the next row; Page Down on the last row moves to the
  trigger / end marker; Page Up moves to the previous row; Ctrl/Cmd+Home moves to
  the first row; Ctrl/Cmd+End moves to the control after the rows. preventDefault
  only when focus actually moved, so an unhandled Page Down still scrolls. The
  current row is derived from `document.activeElement`, so focus on a link INSIDE
  a row still walks correctly. (APG's feed pattern puts Ctrl+Home/End outside the
  feed; a component cannot know what is outside it, so it targets its own first
  row and its own trigger and documents the deviation.)
- `renderItem` fills the INSIDE of a row. The `<article>` wrapper stays owned by
  the component, so no consumer can accidentally drop the position metadata.
- Semantic tokens only: bg-card / border / text-muted-foreground for rows and
  chrome, bg-muted for skeleton bars, border-destructive/40 + bg-destructive/5 +
  text-destructive for both failure branches, ring for every focus-visible ring.
  No hard-coded colours.
- Long content: `min-w-0` plus `wrap-anywhere` (overflow-wrap: anywhere), never
  `break-words` — only the former lowers an element's min-content width, which is
  what actually stops a 100-character URL from making the whole feed scroll
  sideways. Nothing is line-clamped or capped by a fixed height.
- Motion is decoration: the skeleton pulse and the trigger's spinner are both
  `motion-reduce:animate-none`, and every state the component can be in is
  legible with animation off.
- cn() merges the consumer's className into the root, which also spreads the
  remaining div props and forwards its ref.

Customization levers
- Paging feel: `rootMargin` decides how early the next page starts (raise it for
  tall rows or slow endpoints, drop it to "0px" to load only at the true bottom);
  `autoLoadBurst` caps chain-loading; `autoLoad={false}` makes it a pure
  press-to-load list with the sentinel never mounted.
- Container: `maxHeight` switches between an in-page scroll box (the box becomes
  the observer root) and scrolling with the document — the only structural fork
  in the component.
- Row: `renderItem` replaces the row body entirely (media, unread dots, actions);
  keep the wrapper. `skeletonRows` should match the page size closely enough that
  the panel does not jump when the page lands.
- Words: `labels` swaps every string (loadMore / loading / retry / end / empty /
  errorFirst for the first-page failure, errorMore for the inline one — they are
  two headings on purpose), `emptyState` replaces the empty body with your own
  illustration and CTA, `label` names the feed region, `formatStatus` rewrites the announced
  sentence (units, language, or a shorter one for a chatty feed).
- Data shape: extend the zod item schema with your own fields and read them in
  `renderItem`; `total` may stay null forever — the component degrades to
  "12 items loaded" and `aria-setsize="-1"` without a second code path.

Concepts

  • Sentinel with a rootMargin — paging is triggered by an inert 1px node below the last row, not by a scroll handler doing arithmetic on every frame. The rootMargin is the whole point: it fires while the node is still a screenful away, so the next page is already landing by the time the reader gets there. Inside a maxHeight box, that box is the observer's root — with the viewport as root, a node inside an inner scroller looks permanently visible.
  • The in-flight lock is a ref, not state — two clicks in one task, or a click that lands in the same task as the observer callback, both run before React re-renders; a state flag would still read false on the second one and fetch the same page twice. The ref is read and written in the same synchronous handler, and reopens only when the consumer's data observably moves.
  • A failure never costs you what you already loaded — the error is an inline row after the last item, the rows above stay mounted, and Try again re-fetches the same page. The sentinel unmounts for as long as the error stands, so auto-loading cannot quietly hammer a broken endpoint; only a human press resumes it.
  • The manual trigger is the accessible path — a sentinel only ever fires on scroll, so on its own it is unreachable by keyboard and invisible to a screen-reader user navigating by landmarks. The button is always rendered, is never natively disabled (that would blur it mid-action), and Page Down past the last row lands exactly on it.
  • role="feed" is one tab stop, not one per row — Page Up / Page Down walk the articles, Ctrl+Home / Ctrl+End jump to the first row and to the control after them, and each row carries aria-posinset / aria-setsize so "article 12 of 24" is spoken even though only 12 rows exist in the DOM. aria-setsize="-1" is the honest value while the total is unknown.
  • Appending must not move the reading position — rows keyed by id are never re-created, and the footer opts out of scroll anchoring (overflow-anchor: none). Without that, the browser may anchor to the trigger and hold it under the cursor while the page you just loaded slides past unseen.

On This Page