Primitives Navigation
Pagination
A navigation landmark with links to pages; the current page carries aria-current="page". The application decides which pages to list and renders the links; the package styles them. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - 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"
On this page
Example
<nav aria-label="Pagination" data-slean="pagination">
<ul>
<li><a href="?page=4" rel="prev" data-slean-part="previous">Previous</a></li>
<li><a href="?page=1">1</a></li>
<li data-slean-part="ellipsis" aria-hidden="true">…</li>
<li><a href="?page=4">4</a></li>
<li><a href="?page=5" aria-current="page">5</a></li>
<li><a href="?page=6">6</a></li>
<li data-slean-part="ellipsis" aria-hidden="true">…</li>
<li><a href="?page=12">12</a></li>
<li><a href="?page=6" rel="next" data-slean-part="next">Next</a></li>
</ul>
</nav><!-- Pagination.svelte: <Pagination current={5} total={12} /> renders the HTML above. -->
<script lang="ts">
import '@svelte-lean/styles/pagination.css';
import { pageItems } from './pagination-items';
let { current, total }: { current: number; total: number } = $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="Pagination" 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>Why this implementation exists
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
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@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.
Read the page number from the URL and compute the items with a helper like the one below. A table that pages in place has its own pagination (see the table’s Pagination page).
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. |
Runtime profile
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.
Accessibility contract
- 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 itshref: it would still be followed. - The ellipsis is
aria-hidden="true".
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) |
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
Render the links from the page number in the URL. Every link is a URL the server can render.
Before hydration
Nothing is attached. The links work before hydration; afterwards a client-side router may handle them without changing the markup.
Styling
pagination.css sizes each link to the small control height (which grows on a coarse pointer), gives the current page the soft accent, sets an end without href in the muted text color, sets the ellipsis in the muted text color and draws the previous and next chevrons with borders, 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 | 0.25rem | gap, margin-inline-start, margin-inline-end |
--slean-space-2 | 0.5rem | gap |
--slean-control-height-md | 2.25rem | min-inline-size, min-block-size |
--slean-space-3 | 0.75rem | padding-inline |
--slean-radius-md | 0.625rem | 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-accent | oklch(54% 0.19 258) | border-color |
--slean-accent-soft | oklch(95% 0.03 258) | background |
--slean-accent-soft-fg | oklch(42% 0.17 258) | 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 |
State selectors the stylesheet targets, all from the platform or ARIA: :dir(rtl), :focus-visible, :hover, [aria-current="page"], [href].
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 above.
<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} />Compatibility notes
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.
Examples
First page
On the first page, previous has no href. An <a> without href is not a link: Tab skips it, a screen reader does not list it, and the stylesheet
sets it in the muted text color while it keeps its place. On the last page the same holds for next.
<nav aria-label="Pagination" data-slean="pagination">
<ul>
<!-- No href: not a link, not focusable, muted. -->
<li><a data-slean-part="previous">Previous</a></li>
<li><a href="?page=1" aria-current="page">1</a></li>
<li><a href="?page=2">2</a></li>
<li><a href="?page=3">3</a></li>
<li><a href="?page=2" rel="next" data-slean-part="next">Next</a></li>
</ul>
</nav>Page items
Which pages to list is the application’s decision. This helper, used by the example above, keeps the first and last page and one page on each side of the current one; it is not part of any package.
/**
* 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;
}Testing
apps/playground/tests/primitives/feedback-navigation.spec.tsPlaywright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/pagination/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/pagination/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/pagination.cssthe optional stylesheet