Component
Pagination with Numbers
Explicit numbered page navigation for small, known page counts: one aria-labeled control per page with aria-current on the active page.
- Component
- React
- TSX
- Tailwind CSS
- MIT
What it does
Capabilities
- Semantic nav landmark
- Aria-current="page"
- One control per page
- Nav landmark with aria-label
- Accessible control labels
- Aria-disabled disabled states
- Native anchors or buttons
Preview
Live component
9:41 100%
devsnips.site/library/react/components/pagination/pagination-with-numbers/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Pagination/pagination-with-numbers React/Components/Pagination/pagination-with-numbers import { createContext, useContext, useState } from "react";
import type { HTMLAttributes, LiHTMLAttributes, ReactNode } from "react";
/**
/**
* DevSnips React Pagination — explicit numbered page navigation. Built on the
* reference compound core: compose one `<PaginationLink>` per page and let
* the current page carry `aria-current="page"`. Intended for small, known
* page counts; for large datasets use the ellipsis variant.
*
* `<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>
);
}
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>;
}
export { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis };
export default Pagination; # Pagination with Numbers
Explicit numbered page navigation for small, known page counts: one aria-labeled control per page with aria-current on the active page.
## Usage
```tsx
import Pagination, {
PaginationContent,
PaginationItem,
PaginationLink,
} from "./pagination-with-numbers";
const [page, setPage] = useState(1);
const totalPages = 6;
<Pagination page={page} totalPages={totalPages} onPageChange={setPage}>
<PaginationContent>
{Array.from({ length: totalPages }, (_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink page={i + 1} />
</PaginationItem>
))}
</PaginationContent>
</Pagination>
```
## 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,
PaginationLink,
} from "./pagination-with-numbers";
const [page, setPage] = useState(1);
const totalPages = 6;
<Pagination page={page} totalPages={totalPages} onPageChange={setPage}>
<PaginationContent>
{Array.from({ length: totalPages }, (_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink page={i + 1} />
</PaginationItem>
))}
</PaginationContent>
</Pagination>
```
## 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.
## 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.
This variant composes only numbered `PaginationLink` controls — no steppers. Map one `PaginationLink` per page; each renders its page number as the default `children` and computes its own accessible name.
## 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`.
Numbered pagination renders every page explicitly, so it is intended for small page counts (roughly up to 8). Mapping `Array.from({ length: totalPages })` guarantees the controls and the `totalPages` prop never disagree. For larger datasets, switch to the ellipsis variant, which windows the range instead of rendering hundreds of buttons.
## 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.
Every numbered control has an explicit accessible name: "Go to page N" for inactive pages and "Page N" with `aria-current="page"` for the active one — assistive technology never hears a bare "3".
## 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
The control list uses `flex-wrap` with a `gap-1` rhythm, so on narrow screens controls wrap onto a second line instead of shrinking below usable sizes or forcing page-level horizontal scrolling. From 375px up, prefer intentional reduction over squeezing: show fewer numbered links (previous/next-only with `pagination-with-previous-next`), or collapse the range with `pagination-with-ellipsis`. Controls keep their full height at every width: 36px at `md`, 32px at `sm`, 44px at `lg` (a comfortable touch target).
## 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 short, stable lists: a handful of search result pages, a small catalog, an admin list. Above ~8 pages the row gets crowded on mobile — move to `pagination-with-ellipsis`. 345 lines UTF-8 · LF · Spaces: 2
DevSnips AI ready Claude · Cursor · Windsurf
Agent skill & Model Context Protocol
Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.
devsnips://components/pagination/pagination-with-numbers I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/pagination/pagination-with-numbers/ Install: npx devsnips add React/Components/Pagination/pagination-with-numbers This is the Pagination with Numbers — Explicit numbered page navigation for small, known page counts: one aria-labeled control per page with aria-current on the active page. Use this DevSnips resource as the starting point for my project. Adapt it to my project's stack and conventions, fix any issues, and make it production-ready while preserving its intended design and behavior.
Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.
Resource URI
devsnips://components/pagination/pagination-with-numbers