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

Interactive preview
Open preview
9:41 100%
devsnips.site/library/react/components/pagination/pagination-with-page-size/
fluid · no fixed width 100%
No dependencies Responsive · Light/dark · Focus-visible

Install

Add to your project

terminal
npx devsnips add React/Components/Pagination/pagination-with-page-size
registry path: React/Components/Pagination/pagination-with-page-size

Source

Implementation

code.tsx
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;
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
Prompt instruction
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