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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/infinite-scroll.jsonPrompt
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
rootMarginis 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 amaxHeightbox, 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
falseon 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 carriesaria-posinset/aria-setsizeso "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.
SLA Report
A contract-side availability settlement — real period length, overlap-merged downtime, a weighted or worst-service composite, an error budget allowed to read past 100%, and the service-credit rung the period landed on.
Swipe Actions
A list row whose horizontal drag uncovers real action buttons — resistance, velocity snapping, a full-swipe commit, a second press for destructive actions, and the same actions on the keyboard.