Pagination

Composable pagination controls (Pagination / PaginationContent / PaginationItem / PaginationLink / PaginationPrevious / PaginationNext / PaginationEllipsis). Link-based by default — pair with client-side navigation or server params.

Short (3 pages)

Long range with ellipsis

Installation

pnpm
pnpm dlx @hex-core/cli add pagination

Compact (Prev / Next only)

Drop the numbered links when the total page count would be too noisy (e.g. server-driven cursor pagination, or long catalogues where a numeric sequence offers no signal). The position label keeps users oriented.

tsx
<Pagination>
  <PaginationContent>
    <PaginationItem>
      <PaginationPrevious href="#" />
    </PaginationItem>
    <PaginationItem>
      <span className="px-3 text-sm text-muted-foreground" aria-live="polite">
        Page 4 of 27
      </span>
    </PaginationItem>
    <PaginationItem>
      <PaginationNext href="#" />
    </PaginationItem>
  </PaginationContent>
</Pagination>

With page-size selector

Pair the pagination control with a Select that drives row count per page. Reset to page 1 whenever the size changes so the user doesn't land on a now-empty range.

tsx
"use client";

import { useState } from "react";
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
  Label,
} from "@/components/ui";

const PAGE_SIZE_OPTIONS = ["10", "25", "50"] as const;
type PageSize = (typeof PAGE_SIZE_OPTIONS)[number];

function isPageSize(value: string): value is PageSize {
  return (PAGE_SIZE_OPTIONS as readonly string[]).includes(value);
}

export function Example() {
  const [pageSize, setPageSize] = useState<PageSize>("25");
  const [pageIndex, setPageIndex] = useState(0);
  return (
    <div className="flex flex-wrap items-center justify-between gap-4">
      <div className="flex items-center gap-2">
        <Label htmlFor="page-size" className="text-sm text-muted-foreground">
          Rows per page
        </Label>
        <Select
          value={pageSize}
          onValueChange={(value) => {
            if (isPageSize(value)) {
              setPageSize(value);
              setPageIndex(0);
            }
          }}
        >
          <SelectTrigger id="page-size" className="h-9 w-[80px]">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {PAGE_SIZE_OPTIONS.map((option) => (
              <SelectItem key={option} value={option}>
                {option}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
      <Pagination className="mx-0 w-auto justify-end">
        <PaginationContent>
          <PaginationItem>
            <PaginationPrevious
              href="#"
              onClick={(event) => {
                event.preventDefault();
                setPageIndex((i) => Math.max(0, i - 1));
              }}
            />
          </PaginationItem>
          <PaginationItem>
            <PaginationLink href="#" isActive>
              {pageIndex + 1}
            </PaginationLink>
          </PaginationItem>
          <PaginationItem>
            <PaginationNext
              href="#"
              onClick={(event) => {
                event.preventDefault();
                setPageIndex((i) => i + 1);
              }}
            />
          </PaginationItem>
        </PaginationContent>
      </Pagination>
    </div>
  );
}

API Reference

PropTypeDefaultDescription
className
stringAdditional CSS classes on the <nav>

AI Guidance

When to use

Use for navigating between pages of a paginated dataset: blog lists, search results, table rows. Use PaginationEllipsis to truncate long ranges.

When not to use

Don't use for infinite scroll (use IntersectionObserver). Don't use for step-by-step wizards (use a stepper). Don't use for fewer than ~3 pages (just show all the items).

Common mistakes

  • Using PaginationLink without href (anchor is not keyboard-reachable)
  • Forgetting isActive on the current page (no visual or aria-current feedback)
  • Showing every page number on long ranges — use PaginationEllipsis to truncate
  • Using onClick-only <button> instead of PaginationLink — loses right-click-open-new-tab affordance; prefer href + Next.js Link when client-side routing is needed

Accessibility

Root is role='navigation' aria-label='pagination'. Active link gets aria-current='page'. Previous/Next have aria-label. Ellipsis is decorative (aria-hidden) with a sr-only 'More pages' label.

Related components

Token budget: 500