---
title: Pagination
description: Pages as links with first and last always reachable, and nothing that breaks middle-click.
source: https://adamjurek.com/components/pagination
---

## Home


Numbered pages with the window everyone already knows: first and last always
visible, one page either side of the current one, an ellipsis where numbers
were elided. Links, not buttons - a page is an address, and pagination that
cannot be opened in a new tab or crawled is state pretending to be navigation.

<!-- ::start:showcase demo="pagination" height="300" -->
<!-- ::end:showcase -->

## Usage

```tsx
import { Pagination } from "@sushindustries/ui";

<Pagination
	page={page}
	pageCount={12}
	hrefFor={(page) => `?page=${page}`}
/>
```

With a typed router, hand it your `Link` through `renderLink` and it never
builds an anchor at all:

```tsx
<Pagination
	page={page}
	pageCount={pageCount}
	hrefFor={(page) => `/components?page=${page}`}
	renderLink={({ href, children, ...props }) => (
		<Link to="/components" search={{ page }} {...props}>
			{children}
		</Link>
	)}
/>
```

## Why it is built this way

The window is computed, not configured: `1, last, page ± 1`, sorted, with a
gap marker wherever two neighbours are not adjacent. A `siblingCount` option
would be a knob for a decision that has one right answer at this size.

`page` clamps rather than 404s inside `Archive`: a bookmarked page 3 of a
filter that now fits on one page shows the last page, not an empty grid.

## What it does not do

It does not own the URL shape - `hrefFor` does, so `?page=3`, `/page/3` and a
typed router's search params all work without this component knowing which.
It renders nothing at one page, because pagination for one page is furniture.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/pagination.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/pagination.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.1 |
| Category | docs · Navigation |
| Files | `pagination.tsx` |
| Dependencies | None |
| Also installs | `icon` |
| Tags | navigation, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Pagination } from "@sushindustries/ui";

export function Example() {
	return (
		<Pagination page={3} pageCount={12} hrefFor={(page) => `?page=${page}`} />
	);
}
```

## What you should see

A row of page links centred under whatever comes above it: a chevron back
(unless page 1), the numbers with an ellipsis where the window skips ahead,
page 3 marked as current, and a chevron forward. Nothing renders at all when
`pageCount` is 1 or fewer - that's correct, not a bug to chase.

## If nothing happens

Check `pageCount` first - one page or fewer is deliberately blank. After
that, `hrefFor` is required and has no default; without a real function the
links have nowhere to go.


## Guides


## Composing it

It's a `<nav>` sized to its own content, centred by `justify-content` on
itself - no parent height or width requirement. It works equally inside a
container with a max-width or full-bleed; the row just stays centred within
whatever it's given.

## When not to use it

When the total page count isn't known ahead of time - an infinite feed, a
search result stream that loads more as you scroll. `Pagination` needs
`pageCount` up front to compute the window; a "load more" button or an
infinite-scroll sentinel is the right shape for an unbounded list.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `page` | `number` | - | 1-based. |
| `pageCount` | `number` | - | Total pages. One or fewer renders nothing - a single page is not a choice. |
| `hrefFor` | `(page: number) => string` | - | Builds the href for a page number; the host's router owns the URL shape. |
| `renderLink?` | `(props: { page: number; href: string; className: string; "aria-current"?: "page"; "aria-label"?: string; "data-dir"?: "next"; children: ReactNode; }) => ReactNode` | - | Rendered around every href, so a router can own navigation. `page` is the number the link leads to, passed alongside the resolved href because a typed router builds its link from a route pattern and params, not from a path that has already been flattened into a string. |

<!-- /generated:api -->

## Notes

`hrefFor` is required even when `renderLink` is supplied - `renderLink` wraps
the anchor, it doesn't build the URL. Its `page` argument is the destination
page number rather than the current one, so a typed router can build `Link`
from a route pattern instead of re-parsing a resolved path.

Nothing clamps `page` to `1..pageCount`. Pass a `page` outside that range and
the component doesn't correct it - the previous/next chevrons still compute
from it, so a caller with a stale `page` value gets links to pages that don't
exist rather than a fallback to the nearest real one.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="pagination" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { Pagination } from "@sushindustries/ui";

interface Result {
	id: string;
	title: string;
}

export function SearchResults({
	page,
	pageCount,
	results,
}: {
	page: number;
	pageCount: number;
	results: readonly Result[];
}) {
	return (
		<section className="container section">
			<ul className="flex flex-col gap-3">
				{results.map((result) => (
					<li key={result.id}>{result.title}</li>
				))}
			</ul>
			<div className="mt-8">
				<Pagination
					page={page}
					pageCount={pageCount}
					hrefFor={(page) => `?page=${page}`}
				/>
			</div>
		</section>
	);
}
```

## What this example is not

A drop-in for a client-side router. Without `renderLink`, every page link is
a plain anchor - clicking one is a full navigation, which is correct for a
server-rendered results page but not what you want if this sits inside a
route that manages `page` as router state.
