nori-ui

Pagination

Cross-platform pagination with auto-compact mobile variant, compound API, page-size selector, range display, and a headless usePagination hook.

12.6 kBgzipped

At a glance

  • Two equivalent APIs: <Pagination page={…} pageCount={…} onPageChange={…} /> for the 80% case and a compound Pagination.Prev / Pagination.Items / Pagination.Item / Pagination.Range / Pagination.PageSize / Pagination.Next form for full layout control.
  • Auto-compact on phones: variant="auto" (the default) renders numbered pages on viewports ≥ PAGINATION_COMPACT_BREAKPOINT (480px) and switches to a ‹ Page X of Y › row below — works the same on web and on iOS / Android via useWindowDimensions. Force one with variant="numbered" | "compact".
  • Sibling- and boundary-aware ellipsis (siblingCount, boundaryCount) using the same algorithm as MUI's usePagination, so AI agents that have learned MUI's behavior get the right defaults.
  • Optional first / last buttons (showFirstLast).
  • hideOnSinglePage={true} by default — drop in confidently and the component disappears when there's only one page of data.
  • Built-in range display — pass itemCount and pageSize plus showRange (or render <Pagination.Range />) to get a localized "Showing 21–30 of 142" string.
  • Built-in page-size selector<Pagination.PageSize options={[10, 25, 50]} /> wraps our own Select. Picking a new size resets the current page to 1.
  • Jumper input<Pagination.Jumper /> lets users type a page number and jump on Enter or blur; clamps out-of-range and ignores non-numeric input.
  • renderItem slot for <Link> integration with any router (Next.js, Expo Router, react-router) — same signature on web and native.
  • Headless usePagination() hook — the same math the component uses, exposed for fully custom UIs.
  • WCAG 2.2 AA: <nav> landmark, aria-current="page" on the active item, debounced aria-live="polite" page-change announcements, every label localizable via useTranslation (pagination.previous, pagination.next, pagination.first, pagination.last, pagination.gotoPage, pagination.range, pagination.pageOf, pagination.pageSizeLabel, pagination.ariaLabel).
  • RTL via dir="rtl" — chevron glyphs and DOM order both flip.

Controlled vs uncontrolled — page and defaultPage

page (paired with onPageChange) puts Pagination in controlled mode. defaultPage puts it in uncontrolled mode and lets the component track the current page itself — handy for stateless lists where the URL or a parent doesn't drive the page.

// Uncontrolled
<Pagination defaultPage={1} pageCount={20} />
 
// Controlled
<Pagination page={page} pageCount={20} onPageChange={setPage} />

Preview

Direction:
Locale:

First / last buttons

For very long lists, surface jump-to-first and jump-to-last with showFirstLast.

Direction:
Locale:

Compact variant

The compact variant is what variant="auto" switches to on phone-width screens. You can also force it on any viewport — useful for dense toolbars.

Wider sibling and boundary windows

Tune how many pages are shown around the current page (siblingCount) and at the start / end (boundaryCount).

Direction:
Locale:

Range display

Pass itemCount and pageSize together with showRange to render the canonical "Showing X–Y of Z" string. The text is localized via useTranslation and updates live as the page changes.

Direction:
Locale:

Compound API

Use compound parts when you need full control over the layout (extra buttons, custom spacing, mixing with other UI). The same context drives both APIs, so behavior matches the shorthand 1:1.

Direction:
Locale:

Page-size selector

<Pagination.PageSize options={[10, 25, 50, 100]} /> wraps the project's Select and reads / writes the current page size from / to the root context. Picking a new size resets the page to 1 and forwards both values through onPageChange(page, { pageSize }).

Direction:
Locale:

Jumper input

For long lists where the user knows the page they want, drop in <Pagination.Jumper /> — a tiny number input that jumps on Enter or blur. Out-of-range values are clamped to [1, pageCount] silently; non-numeric input is ignored. Pair with Pagination.Items for the click-driven nav.

Direction:
Locale:

Custom rendering with renderItem

Replace the default item rendering — useful for SSR-friendly anchor tags or your router's <Link>. Same signature on web and native. The example below uses an <a> tag and prevents default navigation; in a real app you'd return your <Link href="/items?page=N"> of choice.

Just the math: usePagination

For fully custom UIs (drag scrubbers, infinite-scroll page indicators, custom keyboard interactions), use the headless hook. It returns the same item descriptors and actions the component uses internally — without any DOM or RN elements.

See the usePagination hook page for the full API.

When to use which API

Use the shorthand <Pagination items /> when…Use the compound <Pagination> children when…Use usePagination() when…
You want one-line drop-in (the 80% case)You need to mix Range / PageSize / extra UI freelyYou want a totally custom UI (no <View> wrappers, no theming)
The default layout fitsYou want <Link> per item via asChild on Pagination.ItemYou're integrating with a virtualized list, scrubber, or other non-standard UI
You don't need a page-size selectorYou need a page-size selector or live range textYou want to test pagination math without any rendered output

Auto vs forced compact

The variant prop accepts 'auto' | 'numbered' | 'compact'. Defaults to 'auto' — measures useWindowDimensions().width once per render and swaps to compact below PAGINATION_COMPACT_BREAKPOINT (480px). The constant is exported so consumers can override it for an entire app via theme or tokens; per-instance you can simply pass variant="numbered" or variant="compact" to opt out.

Accessibility

  • The wrapper is a real <nav> landmark (web) / accessibilityRole="navigation" (native), with a localized aria-label defaulting to t('pagination.ariaLabel') = 'Pagination'.
  • The current page renders aria-current="page" and uses the t('pagination.currentPage') = 'Current page' label so screen readers don't confuse it with neighboring page numbers.
  • Page changes announce through a hidden role="status" region (aria-live="polite") with a Page X of Y message, debounced 150ms so rapid clicks don't spam the screen reader.
  • Disabled prev / next / first / last expose aria-disabled="true" and intercept presses (no callback fires).
  • The ellipsis renders aria-hidden and is not focusable — assistive tech sees a clean linear sequence of pages.
  • Keyboard: each page item is a Pressable (renders <button> on web, accessible role on native) so Tab moves through them in DOM order; Enter / Space activate.
  • Touch targets are at least the spacing-8 token wide and tall (≥ 32px on the default theme).

Internationalization

Every visible string is sourced from useTranslation. Override individual strings via the per-instance label props (previousLabel, nextLabel, firstLabel, lastLabel, ariaLabel) or replace the whole dictionary via your i18nProvider. Keys:

KeyDefault
pagination.ariaLabel"Pagination"
pagination.previous"Previous page"
pagination.next"Next page"
pagination.first"First page"
pagination.last"Last page"
pagination.ellipsis"More pages"
pagination.currentPage"Current page"
pagination.gotoPage"Go to page {{page}}"
pagination.range"Showing {{from}}–{{to}} of {{total}}"
pagination.pageOf"Page {{page}} of {{total}}"
pagination.pageSizeLabel"Items per page"
pagination.jumperLabel"Go to page"
pagination.jumperPlaceholder"#"

Cursor-based pagination

For prev/next-only cursor APIs, drive the shorthand with pageCount={hasNext ? page + 1 : page} so the next button stays enabled while you have more cursors. Or build a tiny shell with the headless hook plus your own request state — the lib doesn't ship a separate cursor variant because the prev/next surface is identical.

URL-state sync

Pagination is a pure UI primitive — it does not read or write the URL on its own. To wire it to query params, hold page in your route's source of truth (e.g. Next's useSearchParams + router.replace, or Expo Router's router.setParams) and pass it back as the controlled page prop. See the source of any of the demos above for the controlled-state pattern.

Props

PropTypeDefaultDescription
pageCount*numberTotal number of pages. Required.
ariaLabelstringOverride the nav landmark label.
boundaryCountnumberPages always visible at start/end. @defaultValue 1
childrenReactNodeProvide compound children to opt out of the items-array shorthand.
classNamestring
defaultPagenumberInitial page when uncontrolled (1-indexed). @defaultValue 1
direnumRTL override. @defaultValue 'ltr'
firstLabelstring
hideOnSinglePagebooleanHide the entire component when `pageCount <= 1`. @defaultValue true
itemCountnumberTotal item count — needed by `Pagination.Range` and `Pagination.PageSize`.
lastLabelstring
nextLabelstring
onPageChangePaginationOnPageChangeFired on every page (or page-size) change.
pagenumberControlled current page (1-indexed).
pageSizenumberItems per page — needed by `Pagination.Range` and `Pagination.PageSize`.
previousLabelstringOverride individual labels (otherwise sourced from `useTranslation`).
renderItem(args: PaginationRenderItemArgs) => ReactNodeRender-prop slot for each item. Same signature on web + native.
showFirstLastbooleanShow first/last buttons. @defaultValue false
showRangebooleanRender the "Showing X–Y of Z" range automatically. @defaultValue false
siblingCountnumberPages on each side of the current page. @defaultValue 1
testIDstring
variantenumForce a UI variant. `auto` swaps to `compact` on viewports under `PAGINATION_COMPACT_BREAKPOINT` px wide. @defaultValue 'auto'
Preview theme