Component
Disabled Pagination
Disabled-state patterns for page navigation: boundary-disabled steppers, an individually disabled page control, and a fully disabled navigation.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/pagination/pagination-disabled/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Pagination/pagination-disabled React/Components/Pagination/pagination-disabled import { createContext, useContext, useState } from "react";
import type { HTMLAttributes, LiHTMLAttributes, ReactNode } from "react";
/**
/**
* DevSnips React Pagination — disabled-state patterns for page navigation.
* Built on the reference compound core. Previous disables on the first page,
* Next disables on the last page, `disabled` on `<Pagination>` disables every
* control, and `disabled` on `<PaginationLink>` disables one page. Disabled
* controls render as non-interactive spans with `aria-disabled` — they cannot
* be focused or activated.
*
* `<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; # Disabled Pagination
Disabled-state patterns for page navigation: boundary-disabled steppers, an individually disabled page control, and a fully disabled navigation.
## Usage
```tsx
import Pagination, {
PaginationContent,
PaginationItem,
PaginationLink,
PaginationPrevious,
PaginationNext,
} from "./pagination-disabled";
// Boundary disabling is automatic — Previous is disabled on page 1:
<Pagination page={1} totalPages={5} onPageChange={setPage}>
<PaginationContent>
<PaginationItem><PaginationPrevious /></PaginationItem>
<PaginationItem><PaginationLink page={1} /></PaginationItem>
<PaginationItem><PaginationLink page={2} /></PaginationItem>
<PaginationItem><PaginationNext /></PaginationItem>
</PaginationContent>
</Pagination>
// Disable one page (e.g. results still loading):
<PaginationLink page={3} disabled />
// Disable the whole navigation:
<Pagination disabled page={2} totalPages={5} onPageChange={setPage}>…</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,
PaginationPrevious,
PaginationNext,
} from "./pagination-disabled";
// Boundary disabling is automatic — Previous is disabled on page 1:
<Pagination page={1} totalPages={5} onPageChange={setPage}>
<PaginationContent>
<PaginationItem><PaginationPrevious /></PaginationItem>
<PaginationItem><PaginationLink page={1} /></PaginationItem>
<PaginationItem><PaginationLink page={2} /></PaginationItem>
<PaginationItem><PaginationNext /></PaginationItem>
</PaginationContent>
</Pagination>
// Disable one page (e.g. results still loading):
<PaginationLink page={3} disabled />
// Disable the whole navigation:
<Pagination disabled page={2} totalPages={5} onPageChange={setPage}>…</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.
No new primitives — this variant documents and demonstrates the three disabled paths built into the core: automatic boundary disabling on the steppers, `disabled` on a single `PaginationLink`, and `disabled` on the `<Pagination>` root.
## 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`.
Every disabled path renders the control as a non-interactive `<span aria-disabled="true">` instead of an anchor or button: it stays visible (50% opacity, same geometry, no layout shift) but leaves the tab order and cannot be activated by click, Enter, or Space. When a boundary control becomes enabled again it returns to its normal element.
## 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.
Disabled controls are announced as "dimmed"/unavailable via `aria-disabled` and are skipped by keyboard focus, so users cannot land on a control that does nothing. The current page keeps `aria-current="page"` even while the navigation is disabled, preserving position context.
## 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
Do not fake disabled states with `pointer-events: none` on active elements — a disabled control must be a genuinely non-interactive element. This variant's demos cover the first page, the last page, an unavailable middle page, and a fully disabled navigation. 347 lines UTF-8 · LF · Spaces: 2
Continue browsing