Navigation
Cursor Pagination
A load-more control for cursor APIs — one cursor is never fetched twice, the scroll sentinel has a budget, retries keep the rows already on screen, and the count is announced.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pagination-cursor.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Cursor Pagination" component — the
load-more control that sits under a cursor-paginated list (deps: lucide-react;
reuses a useIntersectionObserver hook for the auto-load sentinel).
Contract
- Fully controlled; the parent owns the items, this owns only the request
discipline.
- cursor: string | null — the value the backend returned as nextCursor; null
means "start from the beginning". This exact value is handed back to
onLoadMore so the parent never re-derives it.
- hasNextPage: boolean — false renders the terminal state instead of a trigger.
- isLoading: boolean (required) — together with cursor / loadedCount / error it
is how the component learns a dispatch was consumed.
- loadedCount: number, totalCount?: number | null — drive the announcement;
omit totalCount for a feed with no known total.
- error?: string | null — a failed attempt; the loaded items are never dropped.
- onLoadMore: (cursor: string | null) => void — called at most once per
attempt. The parent must observably react by changing at least one of
cursor / isLoading / error / loadedCount.
- autoLoad?: boolean = false, rootMargin?: string = "200px",
autoLoadBurst?: number = 3, autoLoadErrorLimit?: number = 3.
- labels?: Partial<{ loadMore; loading; retry; group }> and
formatStatus?: (state) => string for wording.
- className merges via cn(); spread the rest of the native div props;
forwardRef to the root.
Behavior
- In-flight guard is a ref, not state. Two clicks inside one tick — or a click
landing in the same tick as the sentinel callback — both run before React has
re-rendered, so a state flag would still read false on the second one and
dispatch a duplicate request for the very same cursor. Set the ref
synchronously at dispatch; open it again in an effect keyed on
JSON.stringify([cursor, isLoading, error, hasNextPage, loadedCount,
totalCount]) — i.e. only once the parent's state observably moved. Declare
that effect before the auto-load effect so ordering inside one commit is
release-then-dispatch.
- Auto-load sentinel: a 1px aria-hidden div observed with IntersectionObserver.
Short pages leave the sentinel on screen after every append, which would
chain-load the whole dataset in one scroll-free burst — cap it. Count
consecutive auto dispatches; stop at autoLoadBurst; refill the budget when
the sentinel stops intersecting (the user scrolled) or on a manual click.
Keep the sentinel mounted whenever autoLoad is on, so its intersecting flag
can never go stale after an unmount.
- Errors always need a human: auto-load never dispatches while error is
non-null. Count consecutive failures (a fresh error, or the same message
arriving at the end of another attempt); at autoLoadErrorLimit the sentinel
is switched off entirely until a page succeeds, so a broken endpoint is not
hammered by a sentinel that never leaves the viewport. A successful page
resets the streak and re-arms it.
- Retrying re-requests the same cursor — a failed page never advances the feed,
and the rows already loaded stay on screen.
- The trigger is never natively disabled: the browser blurs a disabled element
and focus falls to <body> mid-interaction. Use aria-disabled + aria-busy
while loading and let the click handler ignore the event. When the last page
arrives the trigger unmounts for good — if focus would land on <body>, move
it to the status line (tabIndex={-1}) so keyboard users are not dropped.
- Terminal state is an explicit sentence ("… End of list."), not a vanished
button.
- Clamp autoLoadBurst / autoLoadErrorLimit (NaN, Infinity and negatives would
break the budgets); keep every timer-free effect self-cleaning.
Rendering & styling
- Root: div role="group" with an aria-label, flex column, centered.
- One live region only: a role="status" aria-live="polite" aria-atomic="true"
paragraph carrying the whole state sentence ("20 of 60 items loaded.",
"Loading more… 20 of 60 items loaded.", "<error> 20 of 60 items loaded.",
"60 of 60 items loaded. End of list."). Never duplicate the message in a
second visible node — a screen reader would read it twice.
- Numbers via Intl.NumberFormat("en-US") (explicit locale) plus tabular-nums;
the status text uses break-words so a long error message cannot widen the
control.
- Trigger: h-9 rounded-md border bg-card, hover:bg-muted, focus-visible ring;
ArrowDown / Loader2 (animate-spin with motion-reduce:animate-none) /
RotateCcw per state. Error styling uses text-destructive on the status line.
- Semantic tokens only (bg-card / bg-muted / text-muted-foreground /
text-destructive / border / ring); merge consumer className with cn().
Customization levers
- Auto-load policy: autoLoad, rootMargin (how far ahead to pre-fetch),
autoLoadBurst (0 disables auto dispatch entirely, higher values fill taller
viewports), autoLoadErrorLimit (how tolerant of a flaky endpoint).
- Wording and i18n: labels for the trigger and group name, formatStatus for the
whole announced sentence — that one function is also where you switch to
"Showing 20 of 60" or a localized string.
- Density and placement: the root is a plain flex column, so className can turn
it into a bordered footer (border-t), shrink py-4, or align the trigger left.
- Swap the trigger for a spinner-only affordance in autoLoad mode only if you
keep a real focusable control — the button is the accessible fallback that
makes auto-loading optional rather than required.
- Prepend a "load previous" trigger for bidirectional feeds by mounting a
second instance with its own cursor; the guard is per-instance.Concepts
- In-flight guard as a ref — two clicks in one tick both run before React re-renders, so a state flag still reads
falseon the second one. The ref is set synchronously at dispatch and only opens again once the parent's state observably moved (new cursor, loading flip, error, or more items). - Cursor identity, not page index — a cursor API has no page numbers, so "don't fetch this twice" is about a value, not a counter. The same
cursorgoes out at most once per attempt, and a failed attempt keeps it — retry means re-requesting the identical cursor. - Sentinel budget — a page shorter than the viewport leaves the sentinel visible after it lands, which would chain-load everything in one burst. Consecutive auto dispatches are counted and capped; the budget refills when the sentinel leaves the viewport (the user actually scrolled) or on a manual click.
- Circuit breaker — auto-load never retries an error on its own, and after a few consecutive failures the sentinel is switched off entirely until a page succeeds, so a failing endpoint is not hammered by a marker that never scrolls away.
- Announced count, not a silent append — one
role="status"live region carries the whole sentence, so a screen-reader user hears "20 of 60 items loaded." instead of nothing at all. It is the only live region in the control, so nothing gets read twice. - Focus survives the trigger — the button is never natively
disabled(the browser would blur it), and when the last page removes it for good, focus is handed to the status line instead of dropping to the document body.
Nav Rail
A narrow vertical icon navigation bar that widens into a labelled column — keyboard-reachable tooltips, top/bottom zones, badges and a controllable expanded state.
Tab Overflow
A measured tab strip that folds the tabs it cannot fit into a "More" menu, and never lets the selected tab be the one that disappears.