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…

"use client"

import * as React from "react"
import { AlertCircle, ArrowDown, Loader2, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"
import { useIntersectionObserver } from "@/registry/hooks/use-intersection-observer"

export interface PaginationCursorStatusState {
  /** How many items the consumer currently holds. */
  loaded: number
  /** Total item count when the backend reports one, otherwise null. */
  total: number | null
  hasNextPage: boolean
  isLoading: boolean

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pagination-cursor.json

Prompt

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 false on 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 cursor goes 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.

On This Page