Skip to main content

Pagination

Components and hooks for navigating through paged data with cursor-based and offset-based pagination strategies.

Cursor pagination

A pagination component for use with cursor-based pagination. Cursor-based pagination loads data in chunks by using a cursor from the last item on the current page to know where to start the next set. It doesn't let you jump to a specific page or know how many total pages there are, but it's more efficient for large or real-time data sets.

100 per page
import { CursorPagination } from "@ngrok/mantle/pagination"; <CursorPagination.Root defaultPageSize={100}>	<CursorPagination.PageSizeSelect />	<CursorPagination.Buttons hasNextPage hasPreviousPage /></CursorPagination.Root> <CursorPagination.Root defaultPageSize={100}>	<CursorPagination.PageSizeValue />	<CursorPagination.Buttons hasNextPage hasPreviousPage /></CursorPagination.Root> <CursorPagination.Root defaultPageSize={20}>	<CursorPagination.Buttons hasNextPage hasPreviousPage={false} /></CursorPagination.Root>

Controlled page size

Root owns the page size when you pass defaultPageSize. When the page size must survive navigation, browser history moves, or a refetch, own it yourself: pass pageSize with onChangePageSize. PageSizeSelect shows the value you pass, so a change from outside the component reaches the select. The demo holds the page size in React state and changes it from a button; a URL search param that a back button rewrites works the same way.

Page size in state: 10

import { Button } from "@ngrok/mantle/button";import { CursorPagination } from "@ngrok/mantle/pagination";import { useState } from "react"; function ControlledPageSize() {	// Swap useState for your router's search params to keep the page size in the URL.	const [pageSize, setPageSize] = useState(10); 	return (		<div className="flex flex-col gap-4">			<CursorPagination.Root pageSize={pageSize} onChangePageSize={setPageSize}>				<CursorPagination.PageSizeSelect />				<CursorPagination.Buttons hasNextPage hasPreviousPage={false} />			</CursorPagination.Root>			<div className="flex items-center gap-4">				<p className="text-muted text-sm">Page size in state: {pageSize}</p>				<Button					type="button"					appearance="outlined"					intent="neutral"					onClick={() => setPageSize(100)}				>					Set 100 from outside				</Button>			</div>		</div>	);}

Within a data table

CursorPagination pairs with a DataTable: drive its Buttons and PageSizeSelect from a TanStack Table instance.

Client-paginated: the table holds every row, and the paginatedRowModel slices them. Register rowPaginationFeature and createPaginatedRowModel() with tableFeatures. The table owns the page size, so pass table.state.pagination.pageSize as pageSize and the select follows a setPageSize() call from anywhere. Read getCanPreviousPage() / getCanNextPage() for the buttons and call setPageSize() / setPageIndex(0) when the size changes:

Actions
pmt_0001100successuser1@example.com
pmt_0002137processinguser2@example.com
pmt_0003174faileduser3@example.com
pmt_0004211pendinguser4@example.com
pmt_0005248successuser5@example.com
pmt_0006285processinguser6@example.com
pmt_0007322faileduser7@example.com
pmt_0008359pendinguser8@example.com
pmt_0009396successuser9@example.com
pmt_0010433processinguser10@example.com
import {	DataTable,	createColumnHelper,	createPaginatedRowModel,	rowPaginationFeature,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table";import { CursorPagination } from "@ngrok/mantle/pagination"; // The table's initial page size. It must be one of PageSizeSelect's pageSizes// (default 5 | 10 | 20 | 50 | 100).const DEFAULT_PAGE_SIZE = 10; type Payment = {	id: string;	amount: number;	status: "pending" | "processing" | "success" | "failed";	email: string;}; // Register only the features the table uses: pagination and the row model// that slices rows into pages.const features = tableFeatures({	rowPaginationFeature,	paginatedRowModel: createPaginatedRowModel(),}); const columnHelper = createColumnHelper<typeof features, Payment>(); const columns = columnHelper.columns([	columnHelper.accessor("email", {		id: "email",		header: () => <DataTable.Header>Email</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.accessor("status", {		id: "status",		header: () => <DataTable.Header>Status</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.accessor("amount", {		id: "amount",		header: () => <DataTable.Header className="text-right">Amount</DataTable.Header>,		cell: (props) => <DataTable.Cell className="text-right">${props.getValue()}</DataTable.Cell>,	}),]); function PaginatedTable({ data }: { data: Payment[] }) {	const table = useTable({		features,		data,		columns,		initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE } },	}); 	const rows = table.getRowModel().rows; 	return (		<div className="flex flex-col gap-4">			<DataTable.Root table={table}>				<DataTable.Head />				<DataTable.Body>					{rows.length > 0 ? (						rows.map((row) => <DataTable.Row key={row.id} row={row} />)					) : (						<DataTable.EmptyRow>No results.</DataTable.EmptyRow>					)}				</DataTable.Body>			</DataTable.Root>			<CursorPagination.Root				className="flex justify-end"				pageSize={table.state.pagination.pageSize}				onChangePageSize={(size) => {					table.setPageSize(size);					table.setPageIndex(0); // reset to the first page when the size changes				}}			>				<CursorPagination.PageSizeSelect />				<CursorPagination.Buttons					hasPreviousPage={table.getCanPreviousPage()}					hasNextPage={table.getCanNextPage()}					onPreviousPage={() => table.previousPage()}					onNextPage={() => table.nextPage()}				/>			</CursorPagination.Root>		</div>	);}

Server / cursor data: the server returns one page at a time, so there is no paginatedRowModel and no total count. Wire Buttons to your fetch's has-next / has-prev and load callbacks, and show the fixed, server-controlled page size with the read-only PageSizeValue instead of PageSizeSelect. The example simulates the server's cursor state with a page index:

10 per page
import { CursorPagination } from "@ngrok/mantle/pagination";import { useState } from "react"; // The page size is fixed by the server, so surface it read-only with PageSizeValue.const PAGE_SIZE = 10;const TOTAL_PAGES = 3; function ServerCursorPagination() {	// In a real app the has-prev / has-next flags come from your fetch's cursors;	// here we simulate them with a page index.	const [pageIndex, setPageIndex] = useState(0); 	return (		<CursorPagination.Root className="flex justify-end" defaultPageSize={PAGE_SIZE}>			<CursorPagination.PageSizeValue />			<CursorPagination.Buttons				hasPreviousPage={pageIndex > 0}				hasNextPage={pageIndex < TOTAL_PAGES - 1}				onPreviousPage={() => setPageIndex((index) => Math.max(0, index - 1))}				onNextPage={() => setPageIndex((index) => Math.min(TOTAL_PAGES - 1, index + 1))}			/>		</CursorPagination.Root>	);}

See the DataTable pagination recipe for the full table markup.

Composition

Compose the parts of a CursorPagination together to build your own:

CursorPagination.Root├── CursorPagination.PageSizeSelect├── CursorPagination.PageSizeValue└── CursorPagination.Buttons

API Reference

CursorPagination.Root

The root container for cursor-based pagination. It owns the page size that PageSizeSelect and PageSizeValue read. Pass exactly one of defaultPageSize and pageSize.

All props from the HTML div element, plus:

PropTypeDefaultDescription
defaultPageSize?numberThe initial number of items per page. The root owns the value after mount.
pageSize?numberThe controlled number of items per page. Pair it with onChangePageSize; the select shows this value.
onChangePageSize?(pageSize: number) => voidCalled with the next number of items per page when PageSizeSelect changes it.

CursorPagination.Buttons

A pair of previous/next buttons for navigating between pages.

PropTypeDefaultDescription
hasNextPagebooleanWhether there is a next page of data to load.
hasPreviousPagebooleanWhether there is a previous page of data to load.
onNextPage?() => voidCallback called when the next page button is clicked.
onPreviousPage?() => voidCallback called when the previous page button is clicked.

CursorPagination.PageSizeSelect

A select input for changing the number of items per page. Its combobox is named "Items per page" by default, because its visible text is only the current value. Accepts all Select.Trigger props, plus:

PropTypeDefaultDescription
pageSizes?readonly number[][5, 10, 20, 50, 100]The page sizes to choose from. The current page size must be in this list.
onChangePageSize?(pageSize: number) => voidCalled with the next number of items per page when the user picks one, after Root's callback.
aria-label?string"Items per page"The accessible name of the combobox. Override it for localization.

CursorPagination.PageSizeValue

Displays the current page size as a read-only value (e.g. "100 per page"). Accepts standard span attributes.

Offset pagination

useOffsetPagination

A headless hook for managing offset-based pagination state. Offset-based pagination lets you jump to a specific page and know the total number of pages.

Page 1 of 15

import { Button } from "@ngrok/mantle/button";import { useOffsetPagination } from "@ngrok/mantle/pagination"; function OffsetPaginationControls() {	const pagination = useOffsetPagination({ listSize: 150, pageSize: 10 }); 	return (		<div className="flex items-center gap-4">			<p>				Page {pagination.currentPage} of {pagination.totalPages}			</p>			<Button				type="button"				appearance="outlined"				intent="neutral"				disabled={!pagination.hasPreviousPage}				onClick={pagination.previousPage}			>				Previous			</Button>			<Button				type="button"				appearance="outlined"				intent="neutral"				disabled={!pagination.hasNextPage}				onClick={pagination.nextPage}			>				Next			</Button>		</div>	);}

Own the page

The hook owns the page by default. When the page must survive navigation, browser history moves, or a refetch, own it yourself: pass page with onPageChange. Keep the value where you need it, such as a URL search param. Set resetPageOnListSizeChange to false when a refetch can change the list length. The hook then stays on the current page and clamps it to the new page count instead of returning to page 1. The demo holds the page in React state and starts on page 5; the refetch button shrinks the list to 25 items, and the page clamps to 3.

Page 5 of 15, 150 items

import { Button } from "@ngrok/mantle/button";import { useOffsetPagination } from "@ngrok/mantle/pagination";import { useState } from "react"; function ControlledOffsetPagination() {	// Swap useState for your router's search params to keep the page in the URL.	const [page, setPage] = useState(5);	const [listSize, setListSize] = useState(150);	const pagination = useOffsetPagination({		listSize,		pageSize: 10,		page,		onPageChange: setPage,		resetPageOnListSizeChange: false,	}); 	return (		<div className="flex flex-col gap-4">			<p className="text-muted text-sm">				Page {pagination.currentPage} of {pagination.totalPages}, {listSize} items			</p>			<div className="flex flex-wrap gap-2">				<Button					type="button"					appearance="outlined"					intent="neutral"					disabled={!pagination.hasPreviousPage}					onClick={pagination.previousPage}				>					Previous				</Button>				<Button					type="button"					appearance="outlined"					intent="neutral"					disabled={!pagination.hasNextPage}					onClick={pagination.nextPage}				>					Next				</Button>				<Button					type="button"					appearance="outlined"					intent="neutral"					onClick={() => setListSize((size) => (size === 150 ? 25 : 150))}				>					Refetch {listSize === 150 ? 25 : 150} items				</Button>			</div>		</div>	);}

Parameters

ParameterTypeDefaultDescription
listSizenumberThe total number of items in the list. A change resets the page to 1 unless resetPageOnListSizeChange is false.
pageSizenumberThe number of items per page. A change resets the page to 1.
defaultPage?number1The 1-indexed page to start on when uncontrolled.
page?numberThe controlled 1-indexed page. Pair it with onPageChange. currentPage normalizes it: a page past the end clamps, a fraction rounds down, and NaN reads as 1.
onPageChange?(page: number) => voidCalled with the next page when the hook changes it. Not called when the next page equals the stored page. A move onto a normalized currentPage still calls it, so a stale page catches up.
resetPageOnListSizeChange?booleantrueWhether a listSize change resets the page to 1. Set false to keep the page, clamped to the new totalPages.

Return value

The hook returns an object with the current pagination state and navigation helpers:

PropertyTypeDescription
currentPagenumberThe current page number, 1-indexed (starting at 1).
totalPagesnumberThe total number of pages.
hasPreviousPagebooleanWhether there is a previous page.
hasNextPagebooleanWhether there is a next page.
offsetnumberThe offset of the current page within the list.
pageSizenumberThe current number of items per page.
nextPage() => voidGo to the next page.
previousPage() => voidGo to the previous page.
goToPage(page: number) => voidGo to a specific page (clamped to the valid range).
goToFirstPage() => voidGo to the first page.
goToLastPage() => voidGo to the last page.
setPageSize(size: number) => voidSet the number of items per page; resets to the first page.

getOffsetPaginatedSlice

A utility function to get a paginated slice of a list based on the current offset pagination state. Pair it with useOffsetPagination to render only the current page of items.

  • Alpha
  • Bravo
  • Charlie

Page 1 of 3

import { Button } from "@ngrok/mantle/button";import { getOffsetPaginatedSlice, useOffsetPagination } from "@ngrok/mantle/pagination"; const items = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India"]; function PaginatedList() {	const pagination = useOffsetPagination({ listSize: items.length, pageSize: 3 });	// Slice the current page out of the full list: ["Alpha", "Bravo", "Charlie"] on page 1,	// ["Delta", "Echo", "Foxtrot"] on page 2, and so on.	const currentPageItems = getOffsetPaginatedSlice(items, pagination); 	return (		<div className="flex flex-col gap-4">			<ul>				{currentPageItems.map((item) => (					<li key={item}>{item}</li>				))}			</ul>			<div className="flex items-center gap-4">				<p>					Page {pagination.currentPage} of {pagination.totalPages}				</p>				<Button					type="button"					appearance="outlined"					intent="neutral"					disabled={!pagination.hasPreviousPage}					onClick={pagination.previousPage}				>					Previous				</Button>				<Button					type="button"					appearance="outlined"					intent="neutral"					disabled={!pagination.hasNextPage}					onClick={pagination.nextPage}				>					Next				</Button>			</div>		</div>	);}