ComposedPagination
Product pagination wrapper with entry counts, controls, and jump-to-page support.
hardening
components/ui/pagination.tsx
Hardening
This wrapper can update TanStack Router search params in internal mode. Prefer pagination="external" when the consuming app owns pagination state.
Recipe Source
"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/API | Type | Default | Description |
|---|---|---|---|
totalSize | number | - | Total item count. |
pageNumber | number | - | Current 1-indexed page. |
pageSize | number | - | Items per page. |
pagination | `"internal" | "external"` | "internal" |
Accessibility
- Keep previous/next controls enabled only when valid.
- Use
showJumpToPagewith a visible label. - Preserve page button accessible labels.