# Pagination

> Numbered pages whose selected chip glides on a spring, with ellipses in a fixed number of slots so the width never changes, and a compact Page 3 of 12 variant.

- Tier: Free (MIT)
- Interaction: Press
- Page: https://eased.dev/components/pagination
- Installs: motion
- Registry: https://eased.dev/r/pagination.json

## Install

```bash
npx shadcn add https://eased.dev/r/pagination.json
```

Numbered pages with ellipses, previous and next, and a selected chip that glides between pages on a spring. Controlled or uncontrolled, buttons or real links.

## When to use

- Moving through a long, ordered set one page at a time: invoices, search results, an audit log.
- When people need to know where they are and jump to a page, not only "load more".
- The `compact` variant ("Page 3 of 12") for table footers, cards and narrow screens.

## When not to use

- Feeds people skim top to bottom: load more on scroll instead.
- Two to five views of the same thing: use [Segmented](/components/segmented) or [Tabs](/components/tabs).
- Steps in a flow with an order to finish: use a stepper.

## Usage

```tsx invoices-pager.tsx
import { useState } from "react";
import { Pagination } from "@/components/eased/pagination/pagination";

export function InvoicesPager() {
  const [page, setPage] = useState(1);
  return <Pagination count={11} page={page} onPageChange={setPage} aria-label="Invoice pages" />;
}
```

## Examples

### Compact

Previous, the page number (it rolls) and next. Reach for it under about 400px, or when the count matters more than the jump.

```tsx
<Pagination variant="compact" size="sm" count={11} page={page} onPageChange={setPage} />
```

### Links

`getHref` renders real `<a>` elements, so pages can be crawled and opened in a new tab. Each link still calls `onPageChange`.

```tsx
<Pagination count={24} page={page} getHref={(p) => `/blog?page=${p}`} />
```

### More context around the current page

```tsx
<Pagination count={40} defaultPage={18} siblings={2} boundaries={1} />
```

## API reference

### Pagination

| Prop | Type | Default | Description |
|---|---|---|---|
| `count` | `number` | | How many pages there are. |
| `page` | `number` | | The current page, from 1 (controlled). |
| `defaultPage` | `number` | `1` | The starting page (uncontrolled). |
| `onPageChange` | `(page: number) => void` | | |
| `variant` | `"default" \| "compact"` | `"default"` | Numbered pages, or "Page 3 of 12". |
| `size` | `"sm" \| "md"` | `"md"` | 32px for tables and cards, 36px for pages. |
| `siblings` | `number` | `1` | Pages either side of the current one. |
| `boundaries` | `number` | `1` | Pages always shown at each end. |
| `getHref` | `(page: number) => string` | | Render links instead of buttons. |
| `getPageLabel` | `(page: number) => string` | `` `Page ${p}` `` | Each page's accessible name. |
| `disabled` | `boolean` | `false` | |
| `aria-label` | `string` | `"Pagination"` | The landmark's name. |
| `className` | `string` | `""` | Appended to the `nav`'s classes. |

Other `nav` props pass through. `paginationSlots(page, count, siblings, boundaries)` is exported for building your own layout.

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Moves through previous, each page and next. |
| `←` / `→` | Moves focus to the previous or next control in the row. |
| `Enter` / `Space` | Goes to that page. Focus follows the page if it moved to another slot. |

## Accessibility

- A `nav` landmark named by `aria-label`; the current page has `aria-current="page"`.
- Previous and next at the ends are `aria-disabled`, not `disabled`, so focus is never dropped from under the keyboard.
- Ellipses are hidden from screen readers; the compact variant announces "Page 3 of 12" from a polite live region that stays mounted.
- Targets are 32 or 36px and sit edge to edge with a 4px gap, so they carry no extended hit area.

## Motion

The selected chip is the shared Indicator: it glides to the new page on the snappy spring and redirects mid-flight. Past seven pages the row has a fixed number of slots, so its width never changes; when the window shifts under the chip, each slot's number slides 8px in the direction of travel with a 2px blur (snappy in, 100ms out). Page numbers press by colour only, never scale, so the chip measures them true; previous and next press like icon buttons. The compact number rolls. Reduced motion: the chip jumps and numbers change in place.

## Notes for AI

Use Pagination when a list is split into numbered pages the server or client already knows the count of. Wire `page` and `onPageChange` to your data fetch, or `getHref` for URL-driven pages; use `variant="compact"` in table footers and below 400px.

Don't hide previous and next at the ends or add your own selected style or animation to the page buttons: the chip, the slot swap and focus handling are built in. Hide the whole control yourself when there is only one page.

## Related

- [Table](/components/table): pair with the compact variant in its footer.
- [Segmented](/components/segmented): a few named views instead of numbered pages.

## Example

`components/eased/pagination/pagination-demo.tsx`

```tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { NumberRoll } from "../_primitives/number-roll/number-roll";
import { DemoFrame } from "../lib/demo-frame";
import { exitFast, instant, smooth, useSafeReducedMotion } from "../lib/motion";
import { Pagination } from "./pagination";

const PER_PAGE = 6;
const CUSTOMERS = ["Northwind Labs", "Halcyon Health", "Bramble & Co.", "Orbit Freight", "Juniper Legal", "Kestrel Studio", "Mosaic Foods", "Tidewater Bank", "Lumen Robotics", "Fieldnote Press", "Arbor Dental", "Quarry Supply"];
const STATUS = ["Paid", "Paid", "Due", "Paid", "Overdue", "Paid", "Due"] as const;

// 66 invoices, newest first, with amounts that look like real ones.
const INVOICES = Array.from({ length: 66 }, (_, i) => {
  const n = 2_418 - i;
  const date = new Date(Date.UTC(2026, 9, 17 - Math.floor(i * 1.6)));
  return {
    id: `INV-${n}`,
    customer: CUSTOMERS[(i * 5 + Math.floor(i / 6)) % CUSTOMERS.length],
    amount: 180 + ((i * 7_919) % 9_400) + ((i * 37) % 100) / 100,
    status: STATUS[(i * 3) % STATUS.length],
    date,
  };
});

const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const day = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
const TONE = { Paid: "text-text-muted", Due: "text-text-secondary", Overdue: "text-warning-text" } as const;

export function PaginationDemo() {
  const reduced = useSafeReducedMotion();
  const [page, setPage] = useState(1);
  const [dir, setDir] = useState(1);
  const pages = Math.ceil(INVOICES.length / PER_PAGE);
  const rows = INVOICES.slice((page - 1) * PER_PAGE, page * PER_PAGE);
  const first = (page - 1) * PER_PAGE + 1;
  const last = Math.min(page * PER_PAGE, INVOICES.length);

  const go = (next: number) => {
    setDir(next > page ? 1 : -1);
    setPage(next);
  };

  return (
    <DemoFrame
      title="Invoices"
      meta={
        <span className="inline-flex items-baseline">
          <NumberRoll value={first} aria-hidden />–<NumberRoll value={last} aria-hidden />
          <span>&nbsp;of {INVOICES.length}</span>
          <span className="sr-only">{`Invoices ${first} to ${last} of ${INVOICES.length}`}</span>
        </span>
      }
    >
      <div className="relative min-h-0 flex-1 overflow-hidden">
        <AnimatePresence initial={false} mode="popLayout">
          <motion.ul
            key={page}
            className="grid grid-cols-[auto_1fr_auto] content-start px-(--pad) py-1"
            initial={reduced ? { opacity: 0 } : { opacity: 0, x: dir * 12 }}
            animate={{ opacity: 1, x: 0 }}
            exit={reduced ? { opacity: 0, transition: instant } : { opacity: 0, x: dir * -8, transition: exitFast }}
            transition={reduced ? instant : { ...smooth, opacity: { duration: 0.15 } }}
          >
            {rows.map((row) => (
              <li key={row.id} className="col-span-3 grid h-12 grid-cols-subgrid items-center gap-x-4 border-b border-line-subtle last:border-b-0">
                <span className="font-mono text-ui-sm text-text-muted tabular-nums slashed-zero">{row.id}</span>
                <span className="min-w-0">
                  <span className="block truncate text-ui font-medium text-text">{row.customer}</span>
                  <span className="block truncate text-ui-sm text-text-muted">
                    {day.format(row.date)} · <span className={TONE[row.status]}>{row.status}</span>
                  </span>
                </span>
                <span className="text-end text-ui text-text tabular-nums">{money.format(row.amount)}</span>
              </li>
            ))}
          </motion.ul>
        </AnimatePresence>
      </div>
      <div className="flex h-14 shrink-0 items-center justify-center border-t border-line-subtle px-(--pad)">
        {/* Numbered where there is room, compact in a narrow tile. */}
        <Pagination size="sm" count={pages} page={page} onPageChange={go} aria-label="Invoice pages" className="@max-sm:hidden!" />
        <Pagination size="sm" variant="compact" count={pages} page={page} onPageChange={go} aria-label="Invoice pages" className="hidden! -mx-2 flex-1 @max-sm:flex!" />
      </div>
    </DemoFrame>
  );
}
```
