# Pagination

> Links to pages in a nav with aria-current="page"; the application owns the page number.

Page: https://svelte-lean.vercel.app/primitives/pagination · Markdown: https://svelte-lean.vercel.app/primitives/pagination.md · Index: https://svelte-lean.vercel.app/llms.txt

Links to the pages of a result set in a named navigation landmark, for lists whose pages each have their own URL.

```svelte
<!-- Pagination.svelte, used as <Pagination current={5} total={12} /> -->
<script lang="ts">
	import '@svelte-lean/styles/pagination.css';
	import { pageItems } from './pagination-items';

	let {
		current,
		total,
		label = 'Pagination'
	}: { current: number; total: number; label?: string } = $props();

	const href = (page: number) => `?page=${page}`;
	// At an end, previous or next has no href: not a link, not focusable.
	const first = $derived(current <= 1);
	const last = $derived(current >= total);
</script>

<nav aria-label={label} data-slean="pagination">
	<ul>
		<li>
			<a
				href={first ? undefined : href(current - 1)}
				rel={first ? undefined : 'prev'}
				data-slean-part="previous">Previous</a
			>
		</li>
		{#each pageItems(current, total) as item, i (item ?? `gap-${i}`)}
			{#if item === null}
				<li data-slean-part="ellipsis" aria-hidden="true">…</li>
			{:else}
				<li>
					<a href={href(item)} aria-current={item === current ? 'page' : undefined}>{item}</a>
				</li>
			{/if}
		{/each}
		<li>
			<a
				href={last ? undefined : href(current + 1)}
				rel={last ? undefined : 'next'}
				data-slean-part="next">Next</a
			>
		</li>
	</ul>
</nav>
```

The links change this page’s query string and the example follows it; without JavaScript each link loads its page.

## Examples

### Ends of the range

```svelte
<script lang="ts">
	import Pagination from './Pagination.svelte';
</script>

<!-- At an end, previous or next renders without href. -->
<Pagination current={1} total={3} label="First page" />
<Pagination current={12} total={12} label="Last page" />
```

An a without href is not a link: Tab skips it and the stylesheet mutes it in place.

### Many pages

```svelte
<script lang="ts">
	import Pagination from './Pagination.svelte';
</script>

<Pagination current={48} total={250} label="Search results" />
```

### Result summary

```svelte
<script lang="ts">
	import Pagination from './Pagination.svelte';

	const size = 10;
	const count = 117;
	const current = 5;
	const total = Math.ceil(count / size);
</script>

<div class="footer">
	<p>Showing {(current - 1) * size + 1}–{Math.min(current * size, count)} of {count} orders</p>
	<Pagination {current} {total} label="Orders" />
</div>

<style>
	.footer {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: space-between;
		gap: 1rem;
	}
</style>
```

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/pagination.css';
```

Render one link per listed page with `href`, `aria-current="page"` on the current one, `data-slean-part="previous"` and `"next"` around them, and an `aria-hidden` ellipsis item where pages are skipped.

Which pages to list is the application’s decision: the helper below keeps the first and last page and one page on each side of the current one; it is not part of any package. A table that pages in place has its own pagination (see the table’s Pagination page).

`pagination-items.ts`

```ts
/**
 * The items of a pagination: page numbers around `current`, the first and the last page, and
 * `null` where pages are skipped. A gap of one page shows that page instead of an ellipsis.
 * An application helper for /primitives/pagination, shown on the page as source; it is not part
 * of any package.
 */
export function pageItems(current: number, total: number, around = 1): (number | null)[] {
	const pages = new Set([1, total]);
	for (let page = current - around; page <= current + around; page++) {
		if (page >= 1 && page <= total) pages.add(page);
	}
	const items: (number | null)[] = [];
	for (const page of [...pages].sort((a, b) => a - b)) {
		const last = items.at(-1);
		if (typeof last === 'number' && page - last === 2) items.push(last + 1);
		else if (typeof last === 'number' && page - last > 2) items.push(null);
		items.push(page);
	}
	return items;
}
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<nav aria-label="Pagination" data-slean="pagination">` | – | yes | A named navigation landmark around a \<ul\> of \<li\>. |
| page | `<a href>` | – | yes | Named by its number; aria-current="page" on the current one. |
| previous | `<a href rel="prev">` | `previous` | no | No href on the first page: not a link, not focusable, muted. |
| next | `<a href rel="next">` | `next` | no | No href on the last page. |
| ellipsis | `<li aria-hidden="true">…</li>` | `ellipsis` | no | Skipped pages; absent from the accessibility tree. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | anywhere | Moves between the links; an end without href is skipped (native) |
| `Enter` | focus on a link | Follows it (native) |

## Styling

`pagination.css` sizes each link to the control height of its size (`data-size` small, medium or large; the heights grow on a coarse pointer), gives the current page the soft tones of the current color (`data-color` on the root or an ancestor, primary by default), sets an end without `href` and the ellipsis in the muted text color, and draws the previous and next chevrons and the ellipsis as masked icons, the chevrons mirrored under `:dir(rtl)`. Under forced colors only the current page keeps a border. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap, margin-inline-start, margin-inline-end |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding-inline |
| `--slean-control-height-md` | `2.25rem` | min-inline-size, min-block-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-offset` | `2px` | outline-offset |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-color-main` | `var(--slean-primary-main)` | border-color |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color |
| `--slean-font-weight-semibold` | `600` | font-weight |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-icon-chevron-left` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M10 4L6 8l4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-icon-chevron-right` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask-image |
| `--slean-icon-more` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='3.5' cy='8' r='1.25'/%3E%3Ccircle cx='8' cy='8' r='1.25'/%3E%3Ccircle cx='12.5' cy='8' r='1.25'/%3E%3C/svg%3E")` | mask-image |
| `--slean-control-height-sm` | `2rem` | min-inline-size, min-block-size |
| `--slean-text-xs` | `0.75rem` | font-size |
| `--slean-control-height-lg` | `2.75rem` | min-inline-size, min-block-size |
| `--slean-text-md` | `1rem` | font-size |

State selectors the stylesheet targets, all from the platform or ARIA: `:dir(rtl)`, `:focus-visible`, `:hover`, `[aria-current="page"]`, `[href]`.

Variant attributes: `data-size` (`small`, `large`).

## Controlled integration

The application owns the page number, and the URL is its home. In SvelteKit, read it from `page.url` and pass it to the component.

`+page.svelte`

```svelte
<script lang="ts">
	import { page } from '$app/state';
	import Pagination from './Pagination.svelte';

	// The application owns the page number; the URL is its home.
	const current = $derived(Number(page.url.searchParams.get('page')) || 1);
</script>

<Pagination {current} total={12} />
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<nav>`, `<a href>`, `aria-current="page"`, `:dir(rtl)`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<nav> + links + aria-current="page"`

Tier 0: the pagination has no behavior module, the Vite plugin maps `pagination` to no module, and the page ships no Svelte Lean JavaScript for it. The example’s reading of the URL is page code, not a package runtime.

Why this implementation

Pagination is navigation: every page of results has a URL a reader can open, share and come back to. Links in a named `<nav>` give that for free, with focus, Enter, history and a working page without JavaScript; `aria-current="page"` says which one is shown.

Svelte Lean ships the styles and the two decisions markup gets wrong: an end of the range is an `<a>` without `href`, which the browser takes out of the tab order, instead of a link that is only marked disabled; and the ellipsis is hidden from assistive technology.

### The browser owns

- the navigation landmark and its name
- the links: focus, Enter, the URL of every page
- leaving an \<a\> without href out of the tab order and the links list
- the current page state (aria-current)

### Svelte Lean owns

- pagination.css: the page targets, the current page, the muted ends, the ellipsis
- the chevrons of previous and next, mirrored under RTL
- the contract
- documentation

Accessibility

- A named `<nav>` landmark; `aria-current="page"` on the current page’s link.
- Previous and next have visible text; at an end they have no `href`, so they are not links and not focusable.
- Never `aria-disabled="true"` on a link that keeps its `href`: it would still be followed.
- The ellipsis is `aria-hidden="true"`.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Links, <nav>, aria-current` | Widely available | Not applicable |
| `rotate` | Baseline 2022 | A chevron is an unturned corner |
| `:dir()` | Baseline 2023 | The chevrons are not mirrored under RTL |
| `color-mix() for the current page border` | Baseline 2023 | The border stays transparent; the soft fill marks the page |

Without JavaScript

Fully functional: each link loads its page. On this site the page is the same document with a different query string.

Server rendering and hydration

Render the links from the page number in the URL. Every link is a URL the server can render.

Nothing is attached. The links work before hydration; afterwards a client-side router may handle them without changing the markup.

Compatibility

Links, `<nav>` and `aria-current` are widely available. The chevrons use the `rotate` property (Baseline 2022) and `:dir()` (Baseline 2023); the current page’s border uses `color-mix()` (Baseline 2023) and stays transparent without it.

Tests and source

- `apps/playground/tests/primitives/feedback-navigation.spec.ts` Playwright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/pagination/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/pagination/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/pagination.css` the optional stylesheet
