sveltelean Primitives
Versionv0.2.0 GitHub

Example

Page 5 of 12
<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>
Tier 0: the Svelte source is a component that renders the HTML source for page 5 of 12. On this site the links change the page’s query string and the example follows it; without JavaScript each link loads the page with its query.

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/styles
stylesheets
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.

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

PartElementdata-slean-partRequiredNotes
root<nav aria-label="Pagination" data-slean="pagination">–yesA named navigation landmark around a <ul> of <li>.
page<a href>–yesNamed by its number; aria-current="page" on the current one.
previous<a href rel="prev">previousnoNo href on the first page: not a link, not focusable, muted.
next<a href rel="next">nextnoNo href on the last page.
ellipsis<li aria-hidden="true">…</li>ellipsisnoSkipped 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 its href: it would still be followed.
  • The ellipsis is aria-hidden="true".

Keyboard

KeyWhenResult
Tab/Shift+TabanywhereMoves between the links; an end without href is skipped (native)
Enterfocus on a linkFollows it (native)

Platform features

FeatureBaselineOutside the target
Links, <nav>, aria-currentWidely availableNot applicable
rotateBaseline 2022A chevron is an unturned corner
:dir()Baseline 2023The chevrons are not mirrored under RTL
color-mix() for the current page borderBaseline 2023The 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:

TokenDefault (light)Applies to
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-space-10.25remgap, margin-inline-start, margin-inline-end
--slean-space-20.5remgap
--slean-control-height-md2.25remmin-inline-size, min-block-size
--slean-space-30.75rempadding-inline
--slean-radius-md0.625remborder-radius
--slean-font-weight-medium500font-weight
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-offset2pxoutline-offset
--slean-mutedvar(--slean-neutral-3)background
--slean-accentoklch(54% 0.19 258)border-color
--slean-accent-softoklch(95% 0.03 258)background
--slean-accent-soft-fgoklch(42% 0.17 258)color
--slean-font-weight-semibold600font-weight
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-icon-chevron-lefturl("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-righturl("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-moreurl("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.

+page.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} />

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.

first page
<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.

pagination-items.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;
}

Testing

Source