# Pagination

> Pagination with smart ellipses, first / last buttons, page-size select and a result summary.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/pagination
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/pagination.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/pagination-demo.tsx"
import { Pagination } from "@/components/pagination"

export default function PaginationDemo() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-8">
      <Pagination totalItems={240} defaultPage={6} pageSizeOptions={[10, 20, 50]} />
      <Pagination totalItems={48} showSummary={false} showFirstLast variant="ghost" className="justify-center" />
    </div>
  )
}
```

## Source

```tsx title="components/pagination.tsx"
"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"

import { cn } from "@/lib/utils"

export interface PaginationProps extends Omit<React.ComponentProps<"nav">, "onChange"> {
  totalItems: number
  page?: number
  defaultPage?: number
  onPageChange?: (page: number) => void
  pageSize?: number
  defaultPageSize?: number
  /** Show a page-size select when non-empty */
  pageSizeOptions?: number[]
  onPageSizeChange?: (size: number) => void
  /** Pages shown on each side of the current page */
  siblingCount?: number
  showFirstLast?: boolean
  /** "Showing 1–10 of 240" summary text */
  showSummary?: boolean
  variant?: "outline" | "ghost"
}

type Slot = number | "start-gap" | "end-gap"

export function getPageRange(page: number, pageCount: number, siblingCount: number): Slot[] {
  // first + last + current + siblings + two gaps
  const slots = siblingCount * 2 + 5
  if (pageCount <= slots) return Array.from({ length: pageCount }, (_, i) => i + 1)

  const left = Math.max(page - siblingCount, 2)
  const right = Math.min(page + siblingCount, pageCount - 1)
  const showLeftGap = left > 3
  const showRightGap = right < pageCount - 2
  const edge = siblingCount * 2 + 3

  if (!showLeftGap) return [...Array.from({ length: edge }, (_, i) => i + 1), "end-gap", pageCount]
  if (!showRightGap) return [1, "start-gap", ...Array.from({ length: edge }, (_, i) => pageCount - edge + 1 + i)]
  return [1, "start-gap", ...Array.from({ length: right - left + 1 }, (_, i) => left + i), "end-gap", pageCount]
}

export function Pagination({
  totalItems,
  page: pageProp,
  defaultPage = 1,
  onPageChange,
  pageSize: pageSizeProp,
  defaultPageSize = 10,
  pageSizeOptions = [],
  onPageSizeChange,
  siblingCount = 1,
  showFirstLast = false,
  showSummary = true,
  variant = "outline",
  className,
  ...props
}: PaginationProps) {
  const [innerPage, setInnerPage] = React.useState(defaultPage)
  const [innerSize, setInnerSize] = React.useState(defaultPageSize)
  const pageSize = pageSizeProp ?? innerSize
  const pageCount = Math.max(1, Math.ceil(totalItems / pageSize))
  const page = Math.min(Math.max(pageProp ?? innerPage, 1), pageCount)

  const go = (next: number) => {
    const clamped = Math.min(Math.max(next, 1), pageCount)
    if (clamped === page) return
    setInnerPage(clamped)
    onPageChange?.(clamped)
  }

  const base = cn(
    "flex h-9 min-w-9 items-center justify-center rounded-lg px-2 text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40",
    variant === "outline" ? "border bg-background hover:bg-accent" : "hover:bg-accent"
  )
  const from = totalItems === 0 ? 0 : (page - 1) * pageSize + 1
  const to = Math.min(page * pageSize, totalItems)

  return (
    <nav
      aria-label="Pagination"
      className={cn("flex flex-wrap items-center justify-between gap-x-6 gap-y-3 text-sm", className)}
      {...props}
    >
      {showSummary && (
        <p className="text-muted-foreground tabular-nums">
          Showing <span className="font-medium text-foreground">{from}</span>–
          <span className="font-medium text-foreground">{to}</span> of{" "}
          <span className="font-medium text-foreground">{totalItems}</span>
        </p>
      )}
      <div className="flex items-center gap-4">
        {pageSizeOptions.length > 0 && (
          <label className="flex items-center gap-2 text-muted-foreground">
            Rows
            <select
              value={pageSize}
              onChange={(event) => {
                const size = Number(event.target.value)
                setInnerSize(size)
                onPageSizeChange?.(size)
                // Keep the first visible row on screen
                const next = Math.min(Math.floor(Math.max(from - 1, 0) / size) + 1, Math.max(1, Math.ceil(totalItems / size)))
                setInnerPage(next)
                if (next !== page) onPageChange?.(next)
              }}
              className="h-9 rounded-lg border bg-background px-2 text-foreground"
            >
              {pageSizeOptions.map((size) => (
                <option key={size} value={size}>
                  {size}
                </option>
              ))}
            </select>
          </label>
        )}
        <ul className="flex flex-wrap items-center gap-1">
          {showFirstLast && (
            <li>
              <button type="button" className={base} onClick={() => go(1)} disabled={page === 1} aria-label="First page">
                <ChevronsLeft className="size-4" />
              </button>
            </li>
          )}
          <li>
            <button type="button" className={base} onClick={() => go(page - 1)} disabled={page === 1} aria-label="Previous page">
              <ChevronLeft className="size-4" />
            </button>
          </li>
          {getPageRange(page, pageCount, siblingCount).map((slot) =>
            typeof slot === "number" ? (
              <li key={slot}>
                <button
                  type="button"
                  onClick={() => go(slot)}
                  aria-current={slot === page ? "page" : undefined}
                  aria-label={`Page ${slot}`}
                  className={cn(
                    base,
                    "aria-[current=page]:border-primary aria-[current=page]:bg-primary aria-[current=page]:font-medium aria-[current=page]:text-primary-foreground"
                  )}
                >
                  {slot}
                </button>
              </li>
            ) : (
              <li key={slot} aria-hidden className="flex w-6 justify-center text-muted-foreground">
                …
              </li>
            )
          )}
          <li>
            <button type="button" className={base} onClick={() => go(page + 1)} disabled={page === pageCount} aria-label="Next page">
              <ChevronRight className="size-4" />
            </button>
          </li>
          {showFirstLast && (
            <li>
              <button type="button" className={base} onClick={() => go(pageCount)} disabled={page === pageCount} aria-label="Last page">
                <ChevronsRight className="size-4" />
              </button>
            </li>
          )}
        </ul>
      </div>
    </nav>
  )
}
```
