Component
Pagination with Ellipsis
Windowed page navigation for large datasets: first/last pages plus a sibling window around the current page, with hidden ranges collapsed to a non-interactive ellipsis.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/pagination/pagination-with-ellipsis/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Pagination/pagination-with-ellipsis React/Components/Pagination/pagination-with-ellipsis import { createContext, useContext, useState } from "react";
import type { HTMLAttributes, LiHTMLAttributes, ReactNode } from "react";
/**
/**
* DevSnips React Pagination — windowed page navigation for large datasets.
* Built on the reference compound core, plus `getPaginationRange` (first /
* last / current +/- `siblingCount`, hidden ranges collapsed to an ellipsis)
* and `<PaginationPages>`, which renders that range inside
* `<PaginationContent>`. The ellipsis is informational only — never a button.
*
* `<Pagination>` renders `<nav aria-label>` and owns the current page
* (controlled via `page` + `onPageChange`, or uncontrolled via `defaultPage`).
* `<PaginationContent>` renders the list of controls; `<PaginationItem>` one
* position. `<PaginationLink>` is a numbered page control,
* `<PaginationPrevious>` / `<PaginationNext>` step one page back / forward,
* and `<PaginationEllipsis>` marks a hidden range of pages.
*
* Controls render as real `<a href>` when `buildHref` (or an explicit `href`)
* is provided — normal browser navigation — and as `<button type="button">`
* for state-driven pagination. Disabled controls render as non-interactive
* spans with `aria-disabled`. The current page carries `aria-current="page"`.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export type PaginationSize = "sm" | "md" | "lg";
const SIZES: Record<PaginationSize, string> = {
sm: "h-8 min-w-8 gap-1 px-2 text-[13px] leading-4 [&_svg]:size-3.5",
md: "h-9 min-w-9 gap-1.5 px-2.5 text-sm leading-5 [&_svg]:size-3.5",
lg: "h-11 min-w-11 gap-2 px-3 text-sm leading-5 [&_svg]:size-4",
};
const CONTROL_BASE_CLASSES =
"inline-flex select-none items-center justify-center whitespace-nowrap rounded-[var(--ds-radius-sm)] font-medium tabular-nums transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const CONTROL_IDLE_CLASSES =
"text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)]";
const CONTROL_ACTIVE_CLASSES =
"border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)]";
const CONTROL_DISABLED_CLASSES = "pointer-events-none opacity-50";
const ICON_WRAP_CLASSES = "inline-flex shrink-0";
const CHEVRON_LEFT = (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m15 6-6 6 6 6" />
</svg>
);
const CHEVRON_RIGHT = (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m9 6 6 6-6 6" />
</svg>
);
function clampPage(page: number, totalPages: number): number {
return Math.min(Math.max(1, page), Math.max(1, totalPages));
}
interface PaginationContextValue {
page: number;
totalPages: number;
setPage: (page: number) => void;
size: PaginationSize;
disabled: boolean;
buildHref?: (page: number) => string;
}
const PaginationContext = createContext<PaginationContextValue | null>(null);
function usePagination(component: string): PaginationContextValue {
const context = useContext(PaginationContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Pagination>.`);
}
return context;
}
export interface PaginationProps extends Omit<HTMLAttributes<HTMLElement>, "onChange"> {
/** Current page, 1-based (controlled). Omit to run uncontrolled. */
page?: number;
/** Initial page, 1-based (uncontrolled). */
defaultPage?: number;
/** Total number of pages (required). */
totalPages: number;
/** Called with the next 1-based page whenever it changes. */
onPageChange?: (page: number) => void;
/** Builds a URL for a page; controls render as real anchors when set. */
buildHref?: (page: number) => string;
/** Control density. */
size?: PaginationSize;
/** Disable every control in the navigation. */
disabled?: boolean;
/** Accessible label for the navigation landmark. */
label?: string;
className?: string;
children?: ReactNode;
}
export function Pagination({
page,
defaultPage = 1,
totalPages,
onPageChange,
buildHref,
size = "md",
disabled = false,
label = "Pagination",
className,
children,
...rest
}: PaginationProps) {
const isControlled = page !== undefined;
const [internal, setInternal] = useState(defaultPage);
const current = clampPage(isControlled ? page : internal, totalPages);
function setPage(next: number) {
const clamped = clampPage(next, totalPages);
if (clamped === current) return;
if (!isControlled) {
setInternal(clamped);
}
onPageChange?.(clamped);
}
return (
<PaginationContext.Provider
value={{ page: current, totalPages, setPage, size, disabled, buildHref }}
>
<nav aria-label={label} className={className} {...rest}>
{children}
</nav>
</PaginationContext.Provider>
);
}
export interface PaginationContentProps extends HTMLAttributes<HTMLUListElement> {
className?: string;
children?: ReactNode;
}
export function PaginationContent({ className, children, ...rest }: PaginationContentProps) {
return (
<ul
className={cx("m-0 flex max-w-full list-none flex-wrap items-center gap-1 p-0", className)}
{...rest}
>
{children}
</ul>
);
}
export interface PaginationItemProps extends LiHTMLAttributes<HTMLLIElement> {
className?: string;
children?: ReactNode;
}
export function PaginationItem({ className, children, ...rest }: PaginationItemProps) {
return (
<li className={cx("inline-flex", className)} {...rest}>
{children}
</li>
);
}
export interface PaginationLinkProps {
/** 1-based page number this control navigates to. */
page: number;
/** Explicit URL for URL-based pagination (overrides `buildHref`). */
href?: string;
/** Disable this page control (renders a non-interactive span). */
disabled?: boolean;
className?: string;
children?: ReactNode;
"aria-label"?: string;
}
export function PaginationLink({
page,
href,
disabled,
className,
children,
"aria-label": ariaLabel,
}: PaginationLinkProps) {
const context = usePagination("PaginationLink");
const isCurrent = page === context.page;
const isDisabled = Boolean(disabled) || context.disabled;
const label = ariaLabel ?? (isCurrent ? `Page ${page}` : `Go to page ${page}`);
const classes = cx(
CONTROL_BASE_CLASSES,
SIZES[context.size],
isCurrent ? CONTROL_ACTIVE_CLASSES : CONTROL_IDLE_CLASSES,
isDisabled && CONTROL_DISABLED_CLASSES,
className,
);
const url = href ?? context.buildHref?.(page);
if (isDisabled) {
return (
<span
aria-disabled="true"
aria-label={label}
aria-current={isCurrent ? "page" : undefined}
className={classes}
>
{children ?? page}
</span>
);
}
if (url !== undefined) {
return (
<a href={url} aria-label={label} aria-current={isCurrent ? "page" : undefined} className={classes}>
{children ?? page}
</a>
);
}
return (
<button
type="button"
onClick={() => context.setPage(page)}
aria-label={label}
aria-current={isCurrent ? "page" : undefined}
className={classes}
>
{children ?? page}
</button>
);
}
interface StepControlProps {
direction: "previous" | "next";
href?: string;
label: string;
className?: string;
}
function StepControl({ direction, href, label, className }: StepControlProps) {
const context = usePagination(direction === "previous" ? "PaginationPrevious" : "PaginationNext");
const target = direction === "previous" ? context.page - 1 : context.page + 1;
const isDisabled = context.disabled || target < 1 || target > context.totalPages;
const classes = cx(
CONTROL_BASE_CLASSES,
SIZES[context.size],
CONTROL_IDLE_CLASSES,
isDisabled && CONTROL_DISABLED_CLASSES,
className,
);
const content =
direction === "previous" ? (
<>
<span aria-hidden="true" className={ICON_WRAP_CLASSES}>
{CHEVRON_LEFT}
</span>
<span>{label}</span>
</>
) : (
<>
<span>{label}</span>
<span aria-hidden="true" className={ICON_WRAP_CLASSES}>
{CHEVRON_RIGHT}
</span>
</>
);
const url = href ?? context.buildHref?.(target);
if (isDisabled) {
return (
<span aria-disabled="true" className={classes}>
{content}
</span>
);
}
if (url !== undefined) {
return (
<a href={url} className={classes}>
{content}
</a>
);
}
return (
<button type="button" onClick={() => context.setPage(target)} className={classes}>
{content}
</button>
);
}
export interface PaginationPreviousProps {
/** Explicit URL for the previous page (overrides `buildHref`). */
href?: string;
/** Visible label (also the accessible name). */
label?: string;
className?: string;
}
export function PaginationPrevious({ href, label = "Previous", className }: PaginationPreviousProps) {
return <StepControl direction="previous" href={href} label={label} className={className} />;
}
export interface PaginationNextProps {
/** Explicit URL for the next page (overrides `buildHref`). */
href?: string;
/** Visible label (also the accessible name). */
label?: string;
className?: string;
}
export function PaginationNext({ href, label = "Next", className }: PaginationNextProps) {
return <StepControl direction="next" href={href} label={label} className={className} />;
}
export interface PaginationEllipsisProps {
className?: string;
}
export function PaginationEllipsis({ className }: PaginationEllipsisProps) {
const context = usePagination("PaginationEllipsis");
return (
<span
className={cx(
"inline-flex select-none items-center justify-center text-[var(--ds-color-muted-foreground)]",
SIZES[context.size],
className,
)}
>
<span aria-hidden="true">…</span>
<span className="sr-only">More pages</span>
</span>
);
}
/** One entry of a computed page range: a 1-based page number or a gap. */
export type PaginationRangeItem = number | "ellipsis";
/**
* Computes the visible page range for large datasets. Always shows the first
* page, the last page, and `siblingCount` pages on each side of the current
* page; hidden ranges collapse to a single "ellipsis" marker. When every page
* fits (totalPages <= 2 * siblingCount + 5) all pages are returned and no
* ellipsis is produced.
*/
export function getPaginationRange(
currentPage: number,
totalPages: number,
siblingCount = 1,
): PaginationRangeItem[] {
const totalNumbers = siblingCount * 2 + 5;
if (totalPages <= totalNumbers) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const left = Math.max(2, currentPage - siblingCount);
const right = Math.min(totalPages - 1, currentPage + siblingCount);
const items: PaginationRangeItem[] = [1];
if (left > 2) {
items.push("ellipsis");
}
for (let page = left; page <= right; page += 1) {
items.push(page);
}
if (right < totalPages - 1) {
items.push("ellipsis");
}
items.push(totalPages);
return items;
}
export interface PaginationPagesProps {
/** Pages shown on each side of the current page. */
siblingCount?: number;
}
/**
* Renders the computed page range as `<PaginationItem>` children of
* `<PaginationContent>`: numbered `<PaginationLink>` controls plus
* `<PaginationEllipsis>` markers for hidden ranges. Never renders hundreds of
* page buttons; never renders an ellipsis when no range is hidden.
*/
export function PaginationPages({ siblingCount = 1 }: PaginationPagesProps) {
const context = usePagination("PaginationPages");
const range = getPaginationRange(context.page, context.totalPages, siblingCount);
return (
<>
{range.map((item, index) =>
item === "ellipsis" ? (
<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={item}>
<PaginationLink page={item} />
</PaginationItem>
),
)}
</>
);
}
export default Pagination; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { createContext, useContext, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const SIZES = {
sm: "h-8 min-w-8 gap-1 px-2 text-[13px] leading-4 [&_svg]:size-3.5",
md: "h-9 min-w-9 gap-1.5 px-2.5 text-sm leading-5 [&_svg]:size-3.5",
lg: "h-11 min-w-11 gap-2 px-3 text-sm leading-5 [&_svg]:size-4"
};
const CONTROL_BASE_CLASSES = "inline-flex select-none items-center justify-center whitespace-nowrap rounded-[var(--ds-radius-sm)] font-medium tabular-nums transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const CONTROL_IDLE_CLASSES = "text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)]";
const CONTROL_ACTIVE_CLASSES = "border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)]";
const CONTROL_DISABLED_CLASSES = "pointer-events-none opacity-50";
const ICON_WRAP_CLASSES = "inline-flex shrink-0";
const CHEVRON_LEFT = <svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m15 6-6 6 6 6" />
</svg>;
const CHEVRON_RIGHT = <svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m9 6 6 6-6 6" />
</svg>;
function clampPage(page, totalPages) {
return Math.min(Math.max(1, page), Math.max(1, totalPages));
}
const PaginationContext = createContext(null);
function usePagination(component) {
const context = useContext(PaginationContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Pagination>.`);
}
return context;
}
function Pagination({
page,
defaultPage = 1,
totalPages,
onPageChange,
buildHref,
size = "md",
disabled = false,
label = "Pagination",
className,
children,
...rest
}) {
const isControlled = page !== undefined;
const [internal, setInternal] = useState(defaultPage);
const current = clampPage(isControlled ? page : internal, totalPages);
function setPage(next) {
const clamped = clampPage(next, totalPages);
if (clamped === current) return;
if (!isControlled) {
setInternal(clamped);
}
onPageChange?.(clamped);
}
return <PaginationContext.Provider
value={{ page: current, totalPages, setPage, size, disabled, buildHref }}
>
<nav aria-label={label} className={className} {...rest}>
{children}
</nav>
</PaginationContext.Provider>;
}
function PaginationContent({ className, children, ...rest }) {
return <ul
className={cx("m-0 flex max-w-full list-none flex-wrap items-center gap-1 p-0", className)}
{...rest}
>
{children}
</ul>;
}
function PaginationItem({ className, children, ...rest }) {
return <li className={cx("inline-flex", className)} {...rest}>
{children}
</li>;
}
function PaginationLink({
page,
href,
disabled,
className,
children,
"aria-label": ariaLabel
}) {
const context = usePagination("PaginationLink");
const isCurrent = page === context.page;
const isDisabled = Boolean(disabled) || context.disabled;
const label = ariaLabel ?? (isCurrent ? `Page ${page}` : `Go to page ${page}`);
const classes = cx(
CONTROL_BASE_CLASSES,
SIZES[context.size],
isCurrent ? CONTROL_ACTIVE_CLASSES : CONTROL_IDLE_CLASSES,
isDisabled && CONTROL_DISABLED_CLASSES,
className
);
const url = href ?? context.buildHref?.(page);
if (isDisabled) {
return <span
aria-disabled="true"
aria-label={label}
aria-current={isCurrent ? "page" : undefined}
className={classes}
>
{children ?? page}
</span>;
}
if (url !== undefined) {
return <a href={url} aria-label={label} aria-current={isCurrent ? "page" : undefined} className={classes}>
{children ?? page}
</a>;
}
return <button
type="button"
onClick={() => context.setPage(page)}
aria-label={label}
aria-current={isCurrent ? "page" : undefined}
className={classes}
>
{children ?? page}
</button>;
}
function StepControl({ direction, href, label, className }) {
const context = usePagination(direction === "previous" ? "PaginationPrevious" : "PaginationNext");
const target = direction === "previous" ? context.page - 1 : context.page + 1;
const isDisabled = context.disabled || target < 1 || target > context.totalPages;
const classes = cx(
CONTROL_BASE_CLASSES,
SIZES[context.size],
CONTROL_IDLE_CLASSES,
isDisabled && CONTROL_DISABLED_CLASSES,
className
);
const content = direction === "previous" ? <>
<span aria-hidden="true" className={ICON_WRAP_CLASSES}>
{CHEVRON_LEFT}
</span>
<span>{label}</span>
</> : <>
<span>{label}</span>
<span aria-hidden="true" className={ICON_WRAP_CLASSES}>
{CHEVRON_RIGHT}
</span>
</>;
const url = href ?? context.buildHref?.(target);
if (isDisabled) {
return <span aria-disabled="true" className={classes}>
{content}
</span>;
}
if (url !== undefined) {
return <a href={url} className={classes}>
{content}
</a>;
}
return <button type="button" onClick={() => context.setPage(target)} className={classes}>
{content}
</button>;
}
function PaginationPrevious({ href, label = "Previous", className }) {
return <StepControl direction="previous" href={href} label={label} className={className} />;
}
function PaginationNext({ href, label = "Next", className }) {
return <StepControl direction="next" href={href} label={label} className={className} />;
}
function PaginationEllipsis({ className }) {
const context = usePagination("PaginationEllipsis");
return <span
className={cx(
"inline-flex select-none items-center justify-center text-[var(--ds-color-muted-foreground)]",
SIZES[context.size],
className
)}
>
<span aria-hidden="true">…</span>
<span className="sr-only">More pages</span>
</span>;
}
function getPaginationRange(currentPage, totalPages, siblingCount = 1) {
const totalNumbers = siblingCount * 2 + 5;
if (totalPages <= totalNumbers) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const left = Math.max(2, currentPage - siblingCount);
const right = Math.min(totalPages - 1, currentPage + siblingCount);
const items = [1];
if (left > 2) {
items.push("ellipsis");
}
for (let page = left; page <= right; page += 1) {
items.push(page);
}
if (right < totalPages - 1) {
items.push("ellipsis");
}
items.push(totalPages);
return items;
}
function PaginationPages({ siblingCount = 1 }) {
const context = usePagination("PaginationPages");
const range = getPaginationRange(context.page, context.totalPages, siblingCount);
return <>
{range.map(
(item, index) => item === "ellipsis" ? <PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem> : <PaginationItem key={item}>
<PaginationLink page={item} />
</PaginationItem>
)}
</>;
}
export { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis, getPaginationRange, PaginationPages };
export default Pagination; # Pagination with Ellipsis
Windowed page navigation for large datasets: first/last pages plus a sibling window around the current page, with hidden ranges collapsed to a non-interactive ellipsis.
## Usage
```tsx
import Pagination, {
PaginationContent,
PaginationItem,
PaginationPages,
PaginationPrevious,
PaginationNext,
} from "./pagination-with-ellipsis";
const [page, setPage] = useState(25);
<Pagination page={page} totalPages={50} onPageChange={setPage}>
<PaginationContent>
<PaginationItem><PaginationPrevious /></PaginationItem>
<PaginationPages />
<PaginationItem><PaginationNext /></PaginationItem>
</PaginationContent>
</Pagination>
// Wider window:
<PaginationPages siblingCount={2} />
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Pagination, {
PaginationContent,
PaginationItem,
PaginationPages,
PaginationPrevious,
PaginationNext,
} from "./pagination-with-ellipsis";
const [page, setPage] = useState(25);
<Pagination page={page} totalPages={50} onPageChange={setPage}>
<PaginationContent>
<PaginationItem><PaginationPrevious /></PaginationItem>
<PaginationPages />
<PaginationItem><PaginationNext /></PaginationItem>
</PaginationContent>
</Pagination>
// Wider window:
<PaginationPages siblingCount={2} />
```
## Props
### `<Pagination>`
| Name | Type | Default | Description |
|---|---|---|---|
| `page` | `number` | — | Current page, 1-based (controlled). |
| `defaultPage` | `number` | `1` | Initial page, 1-based (uncontrolled). |
| `totalPages` | `number` (required) | — | Total number of pages. |
| `onPageChange` | `(page: number) => void` | — | Called with the next 1-based page. |
| `buildHref` | `(page: number) => string` | — | Builds a URL per page; controls render as real anchors. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control density (32px / 36px / 44px). |
| `disabled` | `boolean` | `false` | Disable every control in the navigation. |
| `label` | `string` | `"Pagination"` | Accessible label for the `<nav>` landmark. |
| `className` | `string` | — | Extra classes on the `<nav>`. |
| `children` | `ReactNode` | — | `PaginationContent` composition. |
### `<PaginationContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<ul>`. |
| `children` | `ReactNode` | — | `PaginationItem` elements. |
### `<PaginationItem>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<li>`. |
| `children` | `ReactNode` | — | Usually one page control or an ellipsis. |
### `<PaginationLink>`
| Name | Type | Default | Description |
|---|---|---|---|
| `page` | `number` (required) | — | 1-based page number this control navigates to. |
| `href` | `string` | — | Explicit URL (overrides `buildHref`); renders a real anchor. |
| `disabled` | `boolean` | `false` | Disable this page control (non-interactive span). |
| `aria-label` | `string` | `"Go to page N"` / `"Page N"` | Accessible name override. |
| `className` | `string` | — | Extra classes on the control. |
| `children` | `ReactNode` | the page number | Visible content. |
### `<PaginationPrevious>` / `<PaginationNext>`
| Name | Type | Default | Description |
|---|---|---|---|
| `href` | `string` | — | Explicit URL for the target page (overrides `buildHref`). |
| `label` | `string` | `"Previous"` / `"Next"` | Visible label (also the accessible name). |
| `className` | `string` | — | Extra classes on the control. |
Previous disables automatically on the first page; Next on the last page.
### `<PaginationEllipsis>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the marker. |
Informational only: an `aria-hidden` "…" glyph plus a screen-reader-only "More pages" text. Never a button.
### `getPaginationRange(currentPage, totalPages, siblingCount?)`
Returns an array of 1-based page numbers and `"ellipsis"` markers. Always includes the first page, the last page, and `siblingCount` pages on each side of the current page; hidden ranges collapse to a single marker. When every page fits (`totalPages <= 2 * siblingCount + 5`) all pages are returned — no marker is produced.
### `<PaginationPages>`
| Name | Type | Default | Description |
|---|---|---|---|
| `siblingCount` | `number` | `1` | Pages shown on each side of the current page. |
Renders the computed range as `<PaginationItem>` children of `<PaginationContent>`: numbered `PaginationLink` controls plus `PaginationEllipsis` markers.
## Composition
Pagination is a compound component. Seven primitives compose the pattern:
```tsx
<Pagination totalPages={5} page={page} onPageChange={setPage}>
<PaginationContent>
<PaginationItem><PaginationPrevious /></PaginationItem>
<PaginationItem><PaginationLink page={1} /></PaginationItem>
<PaginationItem><PaginationLink page={2} /></PaginationItem>
<PaginationItem><PaginationLink page={3} /></PaginationItem>
<PaginationItem><PaginationEllipsis /></PaginationItem>
<PaginationItem><PaginationNext /></PaginationItem>
</PaginationContent>
</Pagination>
```
- `Pagination` — the root `<nav aria-label="Pagination">` landmark. Owns the current page (controlled via `page` + `onPageChange`, or uncontrolled via `defaultPage`) and provides it — plus `totalPages`, `size`, `disabled`, and `buildHref` — to every child through context.
- `PaginationContent` — the list of controls (`<ul>`). Wraps onto multiple lines instead of scrolling horizontally.
- `PaginationItem` — one position in the control list (`<li>`).
- `PaginationLink` — a numbered page control. Renders a real `<a href>` when `buildHref` (or an explicit `href`) is set, otherwise a `<button type="button">`. The current page carries `aria-current="page"`.
- `PaginationPrevious` / `PaginationNext` — step one page back / forward. Disabled automatically at the first / last page.
- `PaginationEllipsis` — an informational marker for a hidden range of pages (`aria-hidden` glyph + screen-reader-only "More pages"). Not a button.
`PaginationPages` renders the computed range between the steppers. The ellipsis variant never renders more than `2 * siblingCount + 5` page positions, no matter how large `totalPages` grows.
## Pagination Logic
The root `<Pagination>` owns the current page and clamps every navigation target into `1 … totalPages`, so out-of-range requests are impossible. Both modes are supported:
- **Controlled** — pass `page` + `onPageChange`; the parent owns the state.
- **Uncontrolled** — pass `defaultPage`; the component owns the state.
Controls pick their element from how navigation is driven: with `buildHref` (or an explicit `href`) they render real `<a href>` anchors and the browser performs normal navigation; without URLs they render `<button type="button">` and call `setPage`. `PaginationPrevious` is disabled when the current page is 1, `PaginationNext` when it is `totalPages`.
`getPaginationRange(currentPage, totalPages, siblingCount = 1)` produces the visible range:
- Page 1 of 50 → `1 2 … 50`
- Page 3 of 50 → `1 2 3 4 … 50`
- Page 25 of 50 → `1 … 24 25 26 … 50`
- Page 50 of 50 → `1 … 49 50`
- 5 total pages → `1 2 3 4 5` (everything fits, so no ellipsis)
An ellipsis is only emitted when a range is actually hidden — there is never a marker between adjacent pages, and never more than two markers.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Tab` / `Shift+Tab` | Move focus through the page controls |
| `Enter` / `Space` | Activate the focused button (state-driven pagination) |
| `Enter` | Follow the focused link (URL-based pagination) |
Controls are native anchors or buttons, so they keep their expected browser behavior. The ellipsis is not focusable — it is informational only.
## Accessibility
The structure follows the W3C pagination navigation pattern: a `<nav aria-label="Pagination">` landmark containing a list of controls.
- State-driven controls are native `<button type="button">` elements; URL-based controls are real `<a href>` anchors. No `div` click handlers.
- The current page carries `aria-current="page"` and its accessible name is "Page N"; other pages are named "Go to page N".
- Previous / Next keep visible text labels — the chevron icons are `aria-hidden` decoration.
- Disabled controls are non-interactive spans with `aria-disabled="true"`: not focusable, not activatable.
- The ellipsis glyph is `aria-hidden` with a screen-reader-only "More pages" text; it is never a control.
The ellipsis is informational, not a control: the glyph is `aria-hidden` and a screen-reader-only "More pages" text carries the meaning. It is not focusable and cannot be activated — hidden pages are reached through the first/last window and the steppers.
## States
- **Page control (idle)** — muted foreground; hover shifts to a subtle surface with foreground text.
- **Current page** — bordered surface with a hairline border, foreground text, and `aria-current="page"`. Distinguished by border, surface, and weight together — never by color alone.
- **Previous / Next** — same idle treatment with a directional chevron; the visible text label carries the accessible name.
- **Disabled** — non-interactive `aria-disabled` span at 50% opacity; removed from the tab order.
- **Focus-visible** — `--ds-color-focus-ring` outline on every interactive control in both themes.
## Responsive Behavior
Windowing is the mobile strategy: at any width the control row stays short (7 positions at `siblingCount={1}`), and `flex-wrap` covers the rare narrow overflow. Reduce `siblingCount` rather than shrinking controls on very dense screens.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `text-[var(--ds-color-muted-foreground)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This pagination variant uses the semantic color, radius, shadow, typography, and motion tokens.
## Notes
Use for large datasets: file managers, log viewers, admin tables, search results. The algorithm handles first/last/current pages, small counts, and both boundaries without special cases in your code. 413 lines UTF-8 · LF · Spaces: 2
Continue browsing