ComposedPagination

Product pagination wrapper with entry counts, controls, and jump-to-page support.

hardening

Hardening

This wrapper can update TanStack Router search params in internal mode. Prefer pagination="external" when the consuming app owns pagination state.

Recipe Source

components/ui/pagination.tsx
"use client";import { Input } from "@giddaa-housing/ui/input";import {	PaginationButton,	PaginationContent,	PaginationEllipsis,	PaginationItem,	Pagination as PaginationRoot,} from "@giddaa-housing/ui/pagination";import { useNavigate } from "@tanstack/react-router";import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";import { useId } from "react";import { cn } from "./lib/cn";import { usePagination } from "./lib/use-pagination";interface PaginationBaseProps {	/** Total number of items */	totalSize: number;	/** Current page number (1-indexed) */	pageNumber: number;	/** Total number of items per page */	pageSize: number;	/** Number of siblings on each side of current page, defaults to 1 */	siblings?: number;	/** Number of boundary pages to show, defaults to 1 */	boundaries?: number;	/** Custom class name for the root pagination element */	className?: string;	/** Whether pagination is controlled by the component or by a parent callback */	pagination?: "internal" | "external";	/** Whether to show Previous/Next buttons (default: true) */	showControls?: boolean;	/** Whether to show the page number cells (default: true) */	showPageNumbers?: boolean;	/** Whether to show the jump-to-page input (default: false) */	showJumpToPage?: boolean;	/** Accessible label used for the jump-to-page input */	jumpToPageLabel?: string;}type PaginationProps =	| (PaginationBaseProps & {			pagination?: "internal";			onChange?: never;	  })	| (PaginationBaseProps & {			pagination: "external";			onChange: (page: number) => void;	  });function clampPage(page: number, totalPages: number) {	return Math.min(Math.max(page, 1), totalPages);}interface PaginationJumpToPageProps {	currentPage: number;	label: string;	onSubmit: (page: number) => void;	totalPages: number;}function PaginationJumpToPage({	currentPage,	label,	onSubmit,	totalPages,}: PaginationJumpToPageProps) {	const inputId = useId();	const commitValue = (value: string) => {		if (value.trim() === "") return;		const parsedPage = Number.parseInt(value, 10);		const nextPage = clampPage(parsedPage, totalPages);		onSubmit(nextPage);	};	return (		<PaginationItem>			<label				htmlFor={inputId}				className="ml-2 flex items-center gap-3 text-gdt-sm font-normal text-fg-secondary"			>				<span>{label}</span>				<Input					id={inputId}					type="number"					inputMode="numeric"					min={1}					max={totalPages}					size="sm"					defaultValue={String(currentPage)}					className="w-16 text-center text-fg-primary [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none rounded-md"					aria-label={label}					onBlur={(e) => {						commitValue(e.target.value);					}}					onKeyDown={(event) => {						if (event.key === "Enter") {							event.preventDefault();							commitValue(event.currentTarget.value);						}					}}				/>			</label>		</PaginationItem>	);}export function Pagination({	totalSize,	pageNumber,	pageSize,	siblings = 1,	boundaries = 1,	pagination = "internal",	onChange,	className,	showControls = true,	showPageNumbers = true,	showJumpToPage = false,	jumpToPageLabel = "Jump to page",}: PaginationProps) {	const navigate = useNavigate();	const totalPages = Math.ceil(totalSize / pageSize);	const startIndex = (pageNumber - 1) * pageSize + 1;	const endIndex = Math.min(startIndex + pageSize - 1, totalSize);	const paginationState = usePagination({		total: totalPages,		page: pageNumber,		siblings,		boundaries,	});	const renderEntryInfo = () => {		if (totalSize === 0) {			return "No entries";		}		if (startIndex === endIndex && endIndex === totalSize) {			return `Showing the last entry of ${totalSize} entries`;		}		return `Showing ${startIndex} to ${endIndex} of ${totalSize} entries`;	};	const handlePageChange = (targetPage: number) => {		const nextPage = clampPage(targetPage, totalPages);		if (nextPage === pageNumber) {			return;		}		if (pagination === "external" && onChange) {			onChange(nextPage);			return;		}		void navigate({			// @ts-expect-error shared pagination cannot infer route search type			search: (previous) => ({ ...previous, page: nextPage }),		});	};	const renderPageControl = (targetPage: number, isActive = false) => {		return (			<PaginationButton				page={targetPage}				isActive={isActive}				className="cursor-pointer"				aria-label={`Go to page ${targetPage}`}				onClick={() => handlePageChange(targetPage)}			>				{targetPage}			</PaginationButton>		);	};	const renderPreviousControl = () => {		if (!showControls) {			return null;		}		const targetPage = paginationState.active - 1;		const disabled = paginationState.active === 1;		const appearance = showPageNumbers ? "icon" : "control";		const content = (			<>				<ChevronLeftIcon data-icon="inline-start" className="cn-rtl-flip" />				{showPageNumbers ? null : <span>Previous</span>}			</>		);		return (			<PaginationItem>				<PaginationButton					page={targetPage}					appearance={appearance}					disabled={disabled}					className="cursor-pointer disabled:cursor-not-allowed"					aria-label="Go to previous page"					onClick={() => handlePageChange(targetPage)}				>					{content}				</PaginationButton>			</PaginationItem>		);	};	const renderNextControl = () => {		if (!showControls) {			return null;		}		const targetPage = paginationState.active + 1;		const disabled = paginationState.active === totalPages;		const appearance = showPageNumbers ? "icon" : "control";		const content = (			<>				{showPageNumbers ? null : <span>Next</span>}				<ChevronRightIcon data-icon="inline-end" className="cn-rtl-flip" />			</>		);		return (			<PaginationItem>				<PaginationButton					page={targetPage}					appearance={appearance}					disabled={disabled}					className="cursor-pointer disabled:cursor-not-allowed"					aria-label="Go to next page"					onClick={() => handlePageChange(targetPage)}				>					{content}				</PaginationButton>			</PaginationItem>		);	};	// Don't render if there's only one page or no pages	if (totalPages <= 1) {		return null;	}	return (		<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">			<span className="shrink-0 text-gdt-sm font-normal text-fg-secondary">				{renderEntryInfo()}			</span>			<PaginationRoot className={cn("justify-start md:justify-end", className)}>				<PaginationContent className="justify-end">					{renderPreviousControl()}					{showPageNumbers						? paginationState.range.map((page) => {								if (page === "dots-left" || page === "dots-right") {									return (										<PaginationItem key={page}>											<PaginationEllipsis />										</PaginationItem>									);								}								const targetPage = page as number;								return (									<PaginationItem key={targetPage}>										{renderPageControl(											targetPage,											targetPage === paginationState.active,										)}									</PaginationItem>								);							})						: null}					{renderNextControl()}					{showJumpToPage ? (						<PaginationJumpToPage							key={pageNumber}							currentPage={pageNumber}							label={jumpToPageLabel}							onSubmit={handlePageChange}							totalPages={totalPages}						/>					) : null}				</PaginationContent>			</PaginationRoot>		</div>	);}

Usage

import { Pagination } from "@/components/recipes/pagination";

<Pagination pagination="external" totalSize={120} pageNumber={1} pageSize={10} onChange={setPage} />

Examples

External Pagination

Use external mode when page state is owned by a table, query, or route loader.

Showing 21 to 30 of 120 entries

Props

Prop/APITypeDefaultDescription
totalSizenumber-Total item count.
pageNumbernumber-Current 1-indexed page.
pageSizenumber-Items per page.
pagination`"internal""external"`"internal"

Accessibility

  • Keep previous/next controls enabled only when valid.
  • Use showJumpToPage with a visible label.
  • Preserve page button accessible labels.

On this page