Component
Pagination with Page Size
Pagination combined with a Rows per page selector: a labeled native select beside the nav landmark that changes the page size and resets the current page.
- Component
- React
- TSX
- Tailwind CSS
- MIT
What it does
Capabilities
- Semantic nav landmark
- Aria-current="page"
- Page-size select
- Native select control
- Nav landmark with aria-label
- Accessible control labels
- Aria-disabled disabled states
- Native anchors or buttons
- Labeled native select
- Select outside nav landmark
Preview
Live component
9:41 100%
devsnips.site/library/react/components/pagination/pagination-with-page-size/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Pagination/pagination-with-page-size React/Components/Pagination/pagination-with-page-size import { createContext, useContext, useId, useState } from "react";
import type { ChangeEvent, HTMLAttributes, LiHTMLAttributes, ReactNode } from "react";
/**
/**
* DevSnips React Pagination — pagination combined with a page-size selector.
* Built on the reference compound core, plus `<PaginationPageSize>`: an
* explicitly labeled native `<select>` rendered OUTSIDE the `<nav>` landmark,
* because choosing a page size is a filter, not navigation.
*
* `<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 interface PaginationPageSizeProps {
/** Selected page size (controlled). Omit to run uncontrolled. */
value?: number;
/** Initial page size (uncontrolled); defaults to the first option. */
defaultValue?: number;
/** Called with the selected page size whenever it changes. */
onValueChange?: (pageSize: number) => void;
/** Selectable page sizes. */
options?: number[];
/** Visible label for the select. */
label?: string;
id?: string;
className?: string;
}
/**
* Page-size selector ("Rows per page"). A separate, explicitly labeled native
* `<select>` — deliberately NOT part of the `<nav aria-label="Pagination">`
* landmark, because choosing a page size is a filter, not navigation. Styled
* with the same input tokens as the DevSnips Select family.
*/
export function PaginationPageSize({
value,
defaultValue,
onValueChange,
options = [10, 20, 50, 100],
label = "Rows per page",
id,
className,
}: PaginationPageSizeProps) {
const generatedId = useId();
const selectId = id ?? `page-size-${generatedId}`;
const isControlled = value !== undefined;
const [internal, setInternal] = useState<number>(defaultValue ?? options[0]);
const current = isControlled ? value : internal;
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
const next = Number(event.target.value);
if (!isControlled) {
setInternal(next);
}
onValueChange?.(next);
}
return (
<div className={cx("flex items-center gap-2", className)}>
<label
htmlFor={selectId}
className="whitespace-nowrap text-sm text-[var(--ds-color-muted-foreground)]"
>
{label}
</label>
<div className="relative">
<select
id={selectId}
value={current}
onChange={handleChange}
className="h-9 appearance-none rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-input)] pl-3 pr-8 text-sm text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none"
>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<span
aria-hidden="true"
className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--ds-color-muted-foreground)] [&_svg]:size-3.5"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</div>
</div>
);
}
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, useId, 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 PaginationPageSize({
value,
defaultValue,
onValueChange,
options = [10, 20, 50, 100],
label = "Rows per page",
id,
className
}) {
const generatedId = useId();
const selectId = id ?? `page-size-${generatedId}`;
const isControlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue ?? options[0]);
const current = isControlled ? value : internal;
function handleChange(event) {
const next = Number(event.target.value);
if (!isControlled) {
setInternal(next);
}
onValueChange?.(next);
}
return <div className={cx("flex items-center gap-2", className)}>
<label
htmlFor={selectId}
className="whitespace-nowrap text-sm text-[var(--ds-color-muted-foreground)]"
>
{label}
</label>
<div className="relative">
<select
id={selectId}
value={current}
onChange={handleChange}
className="h-9 appearance-none rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-input)] pl-3 pr-8 text-sm text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none"
>
{options.map((option) => <option key={option} value={option}>
{option}
</option>)}
</select>
<span
aria-hidden="true"
className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--ds-color-muted-foreground)] [&_svg]:size-3.5"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</div>
</div>;
}
export { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis, PaginationPageSize };
export default Pagination; # Pagination with Page Size
Pagination combined with a Rows per page selector: a labeled native select beside the nav landmark that changes the page size and resets the current page.
## Usage
```tsx
import Pagination, {
PaginationContent,
PaginationItem,
PaginationLink,
PaginationPrevious,
PaginationNext,
PaginationPageSize,
} from "./pagination-with-page-size";
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const totalPages = Math.ceil(totalItems / pageSize);
<div className="flex flex-wrap items-center justify-between gap-4">
<PaginationPageSize
value={pageSize}
onValueChange={(size) => { setPageSize(size); setPage(1); }}
options={[10, 20, 50]}
/>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage}>
<PaginationContent>
<PaginationItem><PaginationPrevious /></PaginationItem>
{Array.from({ length: totalPages }, (_, i) => (
<PaginationItem key={i + 1}><PaginationLink page={i + 1} /></PaginationItem>
))}
<PaginationItem><PaginationNext /></PaginationItem>
</PaginationContent>
</Pagination>
</div>
```
## 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,
PaginationPrevious,
PaginationNext,
PaginationPageSize,
} from "./pagination-with-page-size";
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const totalPages = Math.ceil(totalItems / pageSize);
<div className="flex flex-wrap items-center justify-between gap-4">
<PaginationPageSize
value={pageSize}
onValueChange={(size) => { setPageSize(size); setPage(1); }}
options={[10, 20, 50]}
/>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage}>
<PaginationContent>
<PaginationItem><PaginationPrevious /></PaginationItem>
{Array.from({ length: totalPages }, (_, i) => (
<PaginationItem key={i + 1}><PaginationLink page={i + 1} /></PaginationItem>
))}
<PaginationItem><PaginationNext /></PaginationItem>
</PaginationContent>
</Pagination>
</div>
```
## 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.
### `<PaginationPageSize>`
| Name | Type | Default | Description |
|---|---|---|---|
| `value` | `number` | — | Selected page size (controlled). |
| `defaultValue` | `number` | first option | Initial page size (uncontrolled). |
| `onValueChange` | `(pageSize: number) => void` | — | Called with the selected page size. |
| `options` | `number[]` | `[10, 20, 50, 100]` | Selectable page sizes. |
| `label` | `string` | `"Rows per page"` | Visible label for the select. |
| `id` | `string` | — | Explicit id for the label/select association. |
| `className` | `string` | — | Extra classes on the wrapper. |
A real, explicitly labeled native `<select>` — rendered OUTSIDE the `<nav>` landmark, because choosing a page size is a filter, not navigation.
## 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.
The page-size selector is a sibling of `<Pagination>`, never a child: render both inside a plain flex row. `PaginationPageSize` is styled with the same input tokens as the DevSnips Select family, so the two controls read as one system without sharing semantics.
## 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`.
Changing the page size changes `totalPages`, so always reset to page 1 in `onValueChange` (as in the example above) — otherwise the current page can point past the end of the resized list. Recompute `totalPages` as `Math.ceil(totalItems / pageSize)` and pass it to `<Pagination>`; the clamp inside the root guards any remaining edge.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Tab` / `Shift+Tab` | Move focus through the page-size select and the page controls |
| `Enter` / `Space` | Activate the focused button (state-driven pagination) |
| `ArrowUp` / `ArrowDown` (select focused) | Move through page-size options (native behavior) |
The page-size control is a native `<select>`, so it keeps its full native keyboard and screen-reader behavior.
## 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 select has a persistent visible `<label>` ("Rows per page") — not a placeholder — and lives outside the `<nav aria-label="Pagination">` landmark, so screen-reader users hear a filter control followed by a navigation landmark rather than a mixed-up widget.
## 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
Place both controls in a `flex flex-wrap items-center justify-between` row: at 375px the select and the nav stack onto separate lines at full size. Never shrink the select below the input height to keep them on one line.
## 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 data tables and admin lists where the user controls density. Pair with the ellipsis variant when large page sizes can produce many pages. 431 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-page-size I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/pagination/pagination-with-page-size/ Install: npx devsnips add React/Components/Pagination/pagination-with-page-size This is the Pagination with Page Size — Pagination combined with a Rows per page selector: a labeled native select beside the nav landmark that changes the page size and resets the current 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-page-size