Pagination
Preview
Code
ts
import Pagination from '@/components/table/Pagination';src/components/table/Pagination.tsx
AI prompt
text
Build a pagination component in React + TypeScript + Tailwind CSS: numbered page links with ellipsis gaps (or a "page N of M" box), first / prev / next / last steppers, a range line and a rows-per-page select — as a props-driven component with three placements, and as composable parts for custom layouts.
## Look
- Everything `text-xs`. Three placements (`variant`), differing only in the wrapper:
- `bar` (default): full width under a table — `flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-t border-slate-200 dark:border-slate-800 px-4 py-2`.
- `pill`: centred in flow — `mt-3 mx-auto w-fit flex items-center gap-4 rounded-full border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm px-4 py-2 shadow-lg`.
- `floating`: the same pill `fixed bottom-4 left-1/2 -translate-x-1/2 z-20`.
- Left: the range line, slate-600 / dark slate-300 — "**1**–**25** of **1204** members" (numbers semibold). In the pill variants it hides below `sm`.
- Right (`flex items-center gap-3`): a "Rows" label with a small native select (`rounded-md border border-slate-200 dark:border-slate-700 bg-transparent px-1.5 py-0.5`), then the controls in `flex items-center gap-0.5`.
- Steppers: ChevronsLeft / ChevronLeft / ChevronRight / ChevronsRight at 14px, `p-1.5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg`, disabled `opacity-40` with no hover. In pill variants `rounded-full`, dark hover slate-700, and they press in (`active:scale-90`).
- Page links: `min-w-[1.75rem] px-1.5 py-1 rounded-lg` (`rounded-full` in pills), slate-600 with slate-100 hover; current page `bg-indigo-600 text-white font-semibold`.
- Ellipsis: a plain "…" span, `px-1 text-slate-400 select-none` — not a button.
- `navigation="input"` replaces the links with a `w-9` centred input (`rounded-md border border-slate-200 dark:border-slate-700 py-0.5 bg-transparent`) followed by "of **M**".
## Behaviour
- Renders nothing when everything fits on one page. Page changes are clamped to 1…totalPages in one place.
- Which pages to show: `edges` links pinned at each end, `siblings` either side of the current page, gaps between. If totalPages ≤ edges×2 + siblings×2 + 3, list every page. A gap that would hide exactly ONE page shows that page instead ("1 … 3" is no shorter than "1 2 3"). The window keeps its width near the ends. `showEllipsis={false}` lists every page and ignores `edges`.
- The page input is local while typing and commits on blur or Enter (a half-typed "12" never jumps to page 1); a non-number reverts; it resyncs when the page changes.
- `reportTemplate` rewrites the range line with `{first}`, `{last}`, `{total}`, `{page}`, `{totalPages}` (locale-formatted), e.g. "Showing {first} to {last} of {total}". rangeStart is 0 when there are no items.
- Changing the page size is the caller's job (usually also resetting to page 1).
## API
- `totalItems`, `itemsPerPage`, `currentPage`, `onPageChange(page)`, `onItemsPerPageChange(size)`; `itemType` ('rows'), `variant` ('bar' | 'pill' | 'floating'), `navigation` ('pages' | 'input'), `siblings` (1), `edges` (1), `showEllipsis` / `showRange` / `showPageSize` (true), `pageSizes` ([25, 50, 100, 250]), `reportTemplate`.
- Composable parts as static members, sharing state through context so they can be arranged freely: `Pagination.Root` (`total` items, `itemsPerPage`, `page`, `onPageChange`, `siblings`, `edges`, `showEllipsis`, `shape: 'rounded' | 'pill'`), `.Content` (a `nav` `flex flex-wrap items-center gap-2`), `.First` / `.Prev` / `.Next` / `.Last` (children replace the icon), `.Pages` (optional render prop per page), `.Page`, `.Ellipsis`, `.Report` (optional render prop over `{ page, totalPages, total, rangeStart, rangeEnd }`, or `itemType`). A part used outside Root throws an error naming the fix. Build the props-driven component FROM these parts so the two can never drift.
## Accessibility
- `nav aria-label="Pagination"` on Content; steppers labelled "First page", "Previous page", "Next page", "Last page"; each link `aria-label="Page N"` with `aria-current="page"` on the current one; the ellipsis is `aria-hidden`; the input is labelled "Page number".
## Demo
1,204 members, 25 per page, starting on page 3: a `bar` inside a bordered box, and a `pill` below it. Plus a composed layout: "← Newer" Prev, "Showing 31–40 of 480", the page links and "Older →" Next, spread with `justify-between`.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { cn } from '@/lib/cn';
import { pageRange } from '@/lib/page-range';
const PAGE_SIZES = [25, 50, 100, 250];
export type PaginationVariant = 'bar' | 'pill' | 'floating';
export type PaginationNavigation = 'pages' | 'input';
/** The wrapper is the only thing the three placements differ in. */
const WRAPPER = {
bar: 'shrink-0 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-t border-slate-200 dark:border-slate-800 px-4 py-2',
pill: 'shrink-0 mt-3 mx-auto w-fit flex items-center gap-4 rounded-full border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm px-4 py-2 shadow-lg',
floating:
'fixed bottom-4 left-1/2 z-20 -translate-x-1/2 flex items-center gap-4 rounded-full border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm px-4 py-2 shadow-lg',
} as const;
/**
* Page controls plus a page-size selector.
*
* Two axes, deliberately kept on ONE component rather than split into two.
*
* `navigation` is what sits in the middle:
* pages numbered links with ellipsis gaps — the conventional paginator, and
* the only one that shows you where you are without reading a number.
* input a "page N of M" box. Better past a few hundred pages, where numbered
* links stop being a map and start being noise.
*
* `variant` is placement:
* bar a full-width strip with a top border — sits under a table
* pill a centred rounded pill, in flow below the table
* floating the same pill, fixed to the bottom of the viewport
*
* They were four separate components once, differing only in their wrapper.
*
* For a layout the props cannot express, the same control is also available as
* COMPOSABLE PARTS on this component — `Pagination.Root`, `.Content`, `.First`,
* `.Prev`, `.Pages`, `.Page`, `.Ellipsis`, `.Next`, `.Last`, `.Report`. This
* prop-driven entry point is BUILT FROM those parts, so the two cannot drift:
* there is one page button, one ellipsis, one clamp and one range line.
*/
export default function Pagination({
totalItems,
itemsPerPage,
currentPage,
onPageChange,
onItemsPerPageChange,
itemType = 'rows',
variant = 'bar',
navigation = 'pages',
siblings = 1,
edges = 1,
showEllipsis = true,
showRange = true,
showPageSize = true,
pageSizes = PAGE_SIZES,
reportTemplate,
}: {
totalItems: number;
itemsPerPage: number;
currentPage: number;
onPageChange: (page: number) => void;
onItemsPerPageChange: (size: number) => void;
/** Named in the range line: "1–25 of 400 members". */
itemType?: string;
variant?: PaginationVariant;
navigation?: PaginationNavigation;
/** `pages` only — how many page links either side of the current one. */
siblings?: number;
/** `pages` only — how many links pinned at each end. */
edges?: number;
/** `pages` only. With gaps off, every page is listed and `edges` is moot. */
showEllipsis?: boolean;
showRange?: boolean;
showPageSize?: boolean;
/** The choices in the rows-per-page select. */
pageSizes?: readonly number[];
/**
* The range line as a template — `{first}`, `{last}`, `{total}`, `{page}`,
* `{totalPages}` — e.g. "Showing {first} to {last} of {total}" or
* "Page {page} of {totalPages}". Absent, "1–25 of 400 rows".
*/
reportTemplate?: string;
}) {
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
if (totalPages <= 1 && totalItems <= itemsPerPage) return null;
const isPill = variant !== 'bar';
const report = reportTemplate ? (
<Report>
{(r) =>
reportTemplate
.replaceAll('{first}', r.rangeStart.toLocaleString())
.replaceAll('{last}', r.rangeEnd.toLocaleString())
.replaceAll('{total}', r.total.toLocaleString())
.replaceAll('{page}', r.page.toLocaleString())
.replaceAll('{totalPages}', r.totalPages.toLocaleString())
}
</Report>
) : (
<Report itemType={itemType} />
);
return (
<Root
total={totalItems}
itemsPerPage={itemsPerPage}
page={currentPage}
onPageChange={onPageChange}
siblings={siblings}
edges={edges}
showEllipsis={showEllipsis}
shape={isPill ? 'pill' : 'rounded'}
>
<div className={cn(WRAPPER[variant], 'text-xs')}>
{showRange && (isPill ? <span className="hidden sm:block">{report}</span> : report)}
<div className="flex items-center gap-3">
{showPageSize && (
<label className="flex items-center gap-1.5 text-slate-600 dark:text-slate-300">
Rows
<select
value={itemsPerPage}
onChange={(e) => onItemsPerPageChange(Number(e.target.value))}
className="rounded-md border border-slate-200 bg-transparent px-1.5 py-0.5 dark:border-slate-700"
>
{pageSizes.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
)}
<div className="flex items-center gap-0.5">
<First />
<Prev />
{navigation === 'pages' ? <Pages /> : <PageInput />}
<Next />
<Last />
</div>
</div>
</div>
</Root>
);
}
/* ---------------------------------------------------------------------------
COMPOSABLE PARTS.
The prop-driven component above covers the common layout in four props. This
is the other half of that trade: every part is a separate element you arrange
yourself, with anything you like between them — a label, an input, a spinner.
They live in this file rather than a second component because they are the
same control with a different entry point. Split apart they looked like two
components that render identically, which is a worse thing to hand someone
than one component with two ways in.
--------------------------------------------------------------------------- */
type PaginationShape = 'rounded' | 'pill';
type PaginationContextValue = {
page: number;
totalPages: number;
total: number;
itemsPerPage: number;
rangeStart: number;
rangeEnd: number;
isFirst: boolean;
isLast: boolean;
go: (page: number) => void;
tokens: ReturnType<typeof pageRange>;
shape: PaginationShape;
};
const PaginationContext = createContext<PaginationContextValue | null>(null);
/**
* Every part reads its state from here rather than taking props, which is what
* lets them be rearranged freely. The error names the fix, because "cannot read
* property of null" from inside a chevron button is not a useful place to land.
*/
function usePagination(part: string): PaginationContextValue {
const ctx = useContext(PaginationContext);
if (!ctx) throw new Error(`<Pagination.${part}> must be rendered inside <Pagination.Root>`);
return ctx;
}
function Root({
total,
itemsPerPage,
page,
onPageChange,
siblings = 1,
edges = 1,
showEllipsis = true,
shape = 'rounded',
children,
}: {
/** Total number of ITEMS, not pages. */
total: number;
itemsPerPage: number;
page: number;
onPageChange: (page: number) => void;
/** Page links either side of the current one. */
siblings?: number;
/** Page links pinned at each end. */
edges?: number;
showEllipsis?: boolean;
/** Corner radius of every button — `pill` is what the pill placements use. */
shape?: PaginationShape;
children: React.ReactNode;
}) {
const totalPages = Math.max(1, Math.ceil(total / itemsPerPage));
// Clamped here, once, so no individual part has to guard its own arithmetic.
const go = (next: number) => onPageChange(Math.min(Math.max(next, 1), totalPages));
return (
<PaginationContext.Provider
value={{
page,
totalPages,
total,
itemsPerPage,
rangeStart: total > 0 ? (page - 1) * itemsPerPage + 1 : 0,
rangeEnd: Math.min(page * itemsPerPage, total),
isFirst: page <= 1,
isLast: page >= totalPages,
go,
tokens: pageRange({ page, totalPages, siblings, edges, showEllipsis }),
shape,
}}
>
{children}
</PaginationContext.Provider>
);
}
function Content({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<nav
aria-label="Pagination"
className={cn('flex flex-wrap items-center gap-2 text-xs text-slate-600 dark:text-slate-300', className)}
>
{children}
</nav>
);
}
/** The pill shape also presses in on click; the rounded one stays flat under a table. */
const NAV_SHAPE = {
rounded: 'rounded-lg dark:hover:bg-slate-800',
pill: 'rounded-full dark:hover:bg-slate-700 transition active:scale-90 disabled:active:scale-100',
} as const;
const navButton =
'inline-flex items-center gap-1 p-1.5 text-slate-500 transition-colors hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-400';
/** The four steppers differ only in target page, icon and label. */
function stepper(
part: string,
defaultIcon: React.ReactNode,
target: (c: PaginationContextValue) => number,
disabled: (c: PaginationContextValue) => boolean,
label: string,
) {
return function Stepper({ children, className }: { children?: React.ReactNode; className?: string }) {
const ctx = usePagination(part);
return (
<button
type="button"
onClick={() => ctx.go(target(ctx))}
disabled={disabled(ctx)}
aria-label={label}
className={cn(navButton, NAV_SHAPE[ctx.shape], className)}
>
{children ?? defaultIcon}
</button>
);
};
}
const First = stepper('First', <ChevronsLeft className="h-3.5 w-3.5" />, () => 1, (c) => c.isFirst, 'First page');
const Prev = stepper('Prev', <ChevronLeft className="h-3.5 w-3.5" />, (c) => c.page - 1, (c) => c.isFirst, 'Previous page');
const Next = stepper('Next', <ChevronRight className="h-3.5 w-3.5" />, (c) => c.page + 1, (c) => c.isLast, 'Next page');
const Last = stepper('Last', <ChevronsRight className="h-3.5 w-3.5" />, (c) => c.totalPages, (c) => c.isLast, 'Last page');
function Page({
page,
children,
className,
}: {
page: number;
children?: React.ReactNode;
className?: string;
}) {
const ctx = usePagination('Page');
const active = page === ctx.page;
return (
<button
type="button"
onClick={() => ctx.go(page)}
aria-label={`Page ${page}`}
aria-current={active ? 'page' : undefined}
className={cn(
'min-w-[1.75rem] px-1.5 py-1 text-center transition-colors',
ctx.shape === 'pill' ? 'rounded-full' : 'rounded-lg',
active
? 'bg-indigo-600 font-semibold text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800',
className,
)}
>
{children ?? page}
</button>
);
}
function Ellipsis({ children, className }: { children?: React.ReactNode; className?: string }) {
// Not a button. A focusable gap is a keyboard stop that does nothing, and
// `aria-hidden` keeps it out of the reading order as well.
return (
<span aria-hidden className={cn('select-none px-1 text-slate-400', className)}>
{children ?? '…'}
</span>
);
}
function Pages({
children,
className,
}: {
/** Render prop for one page link. Omit for the default `<Pagination.Page>`. */
children?: (page: number) => React.ReactNode;
className?: string;
}) {
const ctx = usePagination('Pages');
return (
<span className={cn('flex items-center gap-0.5', className)}>
{ctx.tokens.map((token, i) =>
token === 'ellipsis' ? (
// eslint-disable-next-line react/no-array-index-key
<Ellipsis key={`gap-${i}`} />
) : children ? (
<span key={token}>{children(token)}</span>
) : (
<Page key={token} page={token} />
),
)}
</span>
);
}
/**
* The "page N of M" box — the `navigation="input"` middle. Typing is local
* until blur or Enter, so a half-typed "12" does not jump to page 1 first.
*/
function PageInput() {
const { page, totalPages, go } = usePagination('PageInput');
const [pageInput, setPageInput] = useState(String(page));
useEffect(() => setPageInput(String(page)), [page]);
const commit = () => {
const n = Number.parseInt(pageInput, 10);
if (Number.isNaN(n)) setPageInput(String(page));
else go(n);
};
return (
<span className="flex items-center gap-1 px-1 text-slate-600 dark:text-slate-300">
<input
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && commit()}
aria-label="Page number"
className="w-9 rounded-md border border-slate-200 bg-transparent py-0.5 text-center dark:border-slate-700"
/>
of <span className="font-semibold">{totalPages}</span>
</span>
);
}
function Report({
children,
itemType,
className,
}: {
/** Render prop over the current numbers. Omit for "1–25 of 480". */
children?: (state: {
page: number;
totalPages: number;
total: number;
rangeStart: number;
rangeEnd: number;
}) => React.ReactNode;
/** Appended to the default line: "1–25 of 480 members". */
itemType?: string;
className?: string;
}) {
const { page, totalPages, total, rangeStart, rangeEnd } = usePagination('Report');
return (
<span className={cn('text-slate-600 dark:text-slate-300', className)}>
{children ? (
children({ page, totalPages, total, rangeStart, rangeEnd })
) : (
<>
<span className="font-semibold">{rangeStart}</span>–
<span className="font-semibold">{rangeEnd}</span> of{' '}
<span className="font-semibold">{total}</span>
{itemType && ` ${itemType}`}
</>
)}
</span>
);
}
Pagination.Root = Root;
Pagination.Content = Content;
Pagination.First = First;
Pagination.Prev = Prev;
Pagination.Pages = Pages;
Pagination.Page = Page;
Pagination.Ellipsis = Ellipsis;
Pagination.Next = Next;
Pagination.Last = Last;
Pagination.Report = Report;
export { Root, Content, First, Prev, Pages, Page, Ellipsis, Next, Last, Report };
Props
| Prop | Type | Default | Description |
|---|---|---|---|
totalItems* | number | — | |
itemsPerPage* | number | — | |
currentPage* | number | — | |
onPageChange* | (page: number) => void | — | |
onItemsPerPageChange* | (size: number) => void | — | |
itemType | string | 'rows' | Named in the range line: "1–25 of 400 members". |
variant | PaginationVariant | 'bar' | |
navigation | PaginationNavigation | 'pages' | |
siblings | number | 1 | `pages` only — how many page links either side of the current one. |
edges | number | 1 | `pages` only — how many links pinned at each end. |
showEllipsis | boolean | true | `pages` only. With gaps off, every page is listed and `edges` is moot. |
showRange | boolean | true | |
showPageSize | boolean | true | |
pageSizes | readonly number[] | PAGE_SIZES | The choices in the rows-per-page select. |
reportTemplate | string | — | The range line as a template — `{first}`, `{last}`, `{total}`, `{page}`, `{totalPages}` — e.g. "Showing {first} to {last} of {total}" or "Page {page} of {totalPages}". Absent, "1–25 of 400 rows". |