Pagination
Page-number navigation with ellipsis handling.
Live demo
01Basic β 5 pages
Current page: 1
Long β 20 pages with ellipsis
Current page: 1 Β· Watch the ellipses adapt as you navigate
Wider middle β siblings=2
Two pages either side of current β 7 numbers in the middle.
Compact β size="sm"
In context β paginated table
| ID | Name | |
|---|---|---|
| 1 | Row 1 | user1@example.com |
| 2 | Row 2 | user2@example.com |
| 3 | Row 3 | user3@example.com |
| 4 | Row 4 | user4@example.com |
| 5 | Row 5 | user5@example.com |
| 6 | Row 6 | user6@example.com |
| 7 | Row 7 | user7@example.com |
| 8 | Row 8 | user8@example.com |
| 9 | Row 9 | user9@example.com |
| 10 | Row 10 | user10@example.com |
Implementation
02<script lang="ts">
import Pagination from '$lib/components/Pagination.svelte';
β
let page = $state(1);
const totalPages = 12;
</script>
β
<Pagination
bind:page
{totalPages}
siblings={1}
onChange={(p) => console.log('navigate to page', p)}
/>Pagination computes a list of page tokens β numbers and ellipsis sentinels β based on current page, total pages, and the siblings count. The first and last pages are always shown; siblings expands the window around current. Prev/Next buttons use the real disabled attribute at the edges, and the active page button carries aria-current="page" so assistive tech announces position correctly.
Logic explainer
03What Does It Do? (Plain English)
A row of page numbers β Prev 1 β¦ 4 5 6 β¦ 20 Next β for stepping through a paginated list of results, table rows, or gallery images. Click a number to jump straight to that page. Click Prev or Next to move one step at a time. The button for the page you're on is highlighted and announced to screen readers as the current page.
When there are too many pages to show every number, the middle of the row collapses into one or two β¦ markers. The first page, the last page, the current page and a configurable number of sibling pages either side of the current one are always preserved β those are the only numbers a user is likely to want to click.
Think of it as a TV channel changer: there's a "+1" and a "-1" at the edges for stepping, and the middle shows the channels closest to the one you're watching, with a hint that there are more above and below.
How It Works (Pseudo-Code)
state:
page = 1 // bindable, 1-indexed
totalPages = 1
siblings = 1 // pages either side of current
derive items[]:
call buildItems(page, totalPages, siblings)
// returns array of `number | 'ellipsis-left' | 'ellipsis-right'`
derive isFirst = page <= 1
derive isLast = page >= totalPages
events:
on click Prev: goTo(page - 1)
on click page-number N: goTo(N)
on click Next: goTo(page + 1)
goTo(target):
clamped = clamp(target, 1, totalPages)
if clamped == page: return // no-op, no callback
page = clamped // mutates bindable
onChange?.(clamped) // optional callback
render:
<nav aria-label="Pagination">
<button disabled={isFirst} aria-label="Go to previous page">Prev</button>
for each item in items:
if item is an ellipsis: <span aria-hidden>β¦</span>
else: <button
aria-label="Go to page {item}"
aria-current={item == page ? 'page' : undefined}
class:active={item == page}
>{item}</button>
<button disabled={isLast} aria-label="Go to next page">Next</button>
</nav>The two reactive primitives are items (the visible row) and the isFirst / isLast flags (which drive the disabled state of Prev/Next). Both are $derived, so any change to page, totalPages, or siblings updates the row and the edge buttons in one go.
The Core Concept: Ellipsis Algorithm
The behaviour that makes a pagination component feel right is which numbers it chooses to show when the row can't fit everything. Get it wrong and the user hits a wall of β¦ with no useful jump targets; get it right and the row stays compact while still surfacing every page they're likely to want.
The algorithm in buildItems(current, total, siblings) answers four questions in order.
1. Is the trail trivial?
if (total <= 1) return [1]Zero or one page β no navigation needed, render a single button labelled 1. This guards against totalPages = 0 (an empty result set), which a naΓ―ve implementation would render as nothing at all.
2. Will every page fit?
totalVisible = siblings * 2 + 5
if (total <= totalVisible) return [1, 2, β¦, total]totalVisible is the count of slots the row would hold at full stretch: first page + last page + current + siblings on each side + two ellipsis markers = 5 + 2 * siblings. With siblings = 1, that's 7 β fewer than 8 pages renders every number, no ellipses. With siblings = 2 it rises to 9.
3. Where is the current page?
leftSib = max(current - siblings, 1)
rightSib = min(current + siblings, total)
showLeftDots = leftSib > 2
showRightDots = rightSib < total - 1The showLeftDots / showRightDots flags ask whether the sibling window has cleared a meaningful gap from each edge. The > 2 and < total - 1 thresholds matter: if the gap would only hide one number, the ellipsis costs more visual noise than the digit it replaces β so we render the digit instead.
4. Which layout wins?
There are three real cases, each producing a different shape:
| Case | Condition | Layout | Example (total=20, siblings=1) |
|---|---|---|---|
| Near start | !showLeftDots && showRightDots |
1, 2, β¦, 2s+3, β¦, total |
page 3 β 1 2 3 4 5 β¦ 20 |
| Near end | showLeftDots && !showRightDots |
1, β¦, total-2s-2, β¦, total |
page 18 β 1 β¦ 16 17 18 19 20 |
| Middle | both flags true | 1, β¦, leftSib..rightSib, β¦, total |
page 10 β 1 β¦ 9 10 11 β¦ 20 |
The asymmetry is deliberate: near-start and near-end cases render 2s + 3 consecutive numbers (5 when siblings = 1), but the middle case renders only 2s + 1 (3). When one side is anchored at the edge, the spare visual budget gets spent on more numbers rather than a redundant ellipsis. Same algorithm Material UI and Mantine use.
Worked example β total = 20, siblings = 1
page |
Output | Case |
|---|---|---|
1β4 |
1 2 3 4 5 β¦ 20 |
near start (page 4 still qualifies β leftSib = 3 is not > 2) |
5 |
1 β¦ 4 5 6 β¦ 20 |
middle |
10 |
1 β¦ 9 10 11 β¦ 20 |
middle |
17β20 |
1 β¦ 16 17 18 19 20 |
near end |
Accessibility Deep-Dive
The wrapper is a real <nav aria-label="Pagination">, making it a landmark that screen-reader users can jump to directly. Each page-number is a real <button type="button"> β not a styled <div> or <a> β so it inherits focus, Enter/Space activation, and disabled-state semantics for free.
aria-label on every button. Plain text 5 inside a button is announced as just "5, button" β useless without context. aria-label="Go to page {N}" overrides the visible label for assistive tech, so the announcement becomes "Go to page 5, button". Same trick on Prev/Next: visible text is Prev, announced label is "Go to previous page".
aria-current="page" on the active button. Screen readers announce "current page" alongside the button label. The active button is deliberately not disabled β goTo short-circuits when clamped === page, so clicking it is a no-op. Disabling it would force keyboard users to skip over their own location when tabbing across the row.
Real disabled, not aria-disabled. When Prev or Next reaches an edge, it gets the native disabled attribute. That removes the button from the tab order and blocks click events. aria-disabled would keep the button tabbable and still firing clicks, requiring manual no-op handling for no benefit.
Keyboard model. No custom key handling β Tab/Shift+Tab walks the row, Enter/Space activates the focused button, :focus-visible paints a 2px outline. No roving tabindex, no arrow keys, no Home/End. Every button is already independently focusable; adding an arrow-key layer over a list with hidden ellipses creates more confusion than it solves.
State Flow Diagram
βββββββββββββββββββββββββββ
β page (bindable) β
β totalPages, siblings β
ββββββββββββββ¬βββββββββββββ
β
β $derived
βΌ
βββββββββββββββββββββββββββ
β items[] β
β isFirst, isLast β
ββββββββββββββ¬βββββββββββββ
β render
βΌ
βββββββββββββββββββββββββββ
β Prev | β¦pagesβ¦ | Next β
ββββββββββββββ¬βββββββββββββ
β
β click
βΌ
βββββββββββββββββββββββββββ
β goTo(target): β
β clamp to [1, total] β
β no-op if unchanged β
β else mutate `page`, β
β fire onChange β
ββββββββββββββ¬βββββββββββββ
β
ββββββββββββΊ (back to top β derived rebuilds)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
page |
number |
1 |
Current page (1-indexed). Bindable via bind:page. Clamped on every navigation to [1, totalPages]. |
totalPages |
number |
1 |
Total number of pages. 0 and 1 both render a single page button. |
siblings |
number |
1 |
Pages either side of the current page to render. Higher values widen the row before any ellipsis appears. |
size |
'sm' | 'md' |
'md' |
Padding and font scale. |
prevLabel |
string |
'Prev' |
Visible text on the previous-page button (the announced label is always "Go to previous page"). |
nextLabel |
string |
'Next' |
Visible text on the next-page button. |
ariaLabel |
string |
'Pagination' |
aria-label on the wrapper <nav>. Override for non-English locales. |
onChange |
(page: number) => void |
β | Fires after a navigation that actually changes the page. Useful for analytics, URL syncing, or data refetches. |
class |
string |
'' |
Extra classes appended to the <nav>. |
Edge Cases
| Situation | Behaviour |
|---|---|
totalPages === 0 |
The total <= 1 guard fires and the component renders a single page button labelled 1. No crash, no negative numbers. |
totalPages === 1 |
Same as above β single button, both Prev and Next are disabled. |
page outside [1, totalPages] |
Every navigation clamps to the valid range. A caller passing page = 999 with totalPages = 10 will render the row for page 10 once goTo runs. The first render still uses the raw value, so callers should clamp before passing. |
| Click the active page | goTo short-circuits: clamped === page, so page doesn't mutate and onChange doesn't fire. Pure no-op. |
siblings = 0 |
The current page is the only middle number. Layouts shrink to 1 2 3 4 β¦ 20, 1 β¦ 4 β¦ 20, 1 β¦ 17 18 19 20. |
siblings larger than totalPages |
Falls through to the "trail fits" branch β every page is rendered, no ellipses. |
User has prefers-reduced-motion: reduce |
Hover-background transitions on buttons are disabled. The component is otherwise non-animated. |
bind:page and onChange both supplied |
Both run on every page change. page mutates first (so the binding settles synchronously), then onChange(clamped) fires. |
Dependencies
- Svelte 5.x β
$bindable,$state,$derived,$props. The two-waybind:pageflow is built on$bindable. - Zero external dependencies β inline SVG arrow icons, pure CSS animation, no motion library.
File Structure
src/lib/components/Pagination.svelte # implementation
src/lib/components/Pagination.md # this file (rendered inside ComponentPageShell)
src/lib/components/Pagination.test.ts # vitest unit tests
src/routes/pagination/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Bindable current page (1-based). |
totalPages | number | 1 | Total pages available. |
siblings | number | 1 | Pages shown either side of current before ellipsing. |
size | 'sm' | 'md' | 'md' | Visual density. |
prevLabel | string | 'Prev' | Label for the previous-page button (i18n hook). |
nextLabel | string | 'Next' | Label for the next-page button. |
ariaLabel | string | 'Pagination' | Accessible name on the wrapping nav. |
onChange | (page: number) => void | undefined | Optional callback fired when page changes. |
class | string | '' | Extra CSS class on the wrapper. |