Pagination
Pagination navigates a server-owned list with real framework-native Inertia links. Pass the current page and the total page count; Klean derives every destination from the current URL, keeps the rest of the query and hash intact, and removes page=1 from the canonical first-page URL.
There is no item schema, link adapter, URL builder, ellipsis setting, visual variant, or router configuration. The Boring Stack already has a Link, so Pagination uses it.
Installation
One command detects Vue, React, or Svelte, installs the matching official Inertia adapter when needed, and writes one editable source file into the conventional component directory:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add pagination- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
There is no initializer, provider, klean-ui.json, generated class helper, or runtime Klean package to configure.
Usage
Render Pagination from the same server pagination object that rendered the visible rows. The server remains authoritative; Klean does not create a second client-side page state.
Vue
<script setup>
import Pagination from '@/components/ui/pagination/Pagination.vue'
defineProps({
pagination: {
type: Object,
required: true
}
})
</script>
<template>
<Pagination
:page="pagination.page"
:pages="pagination.totalPages"
:only="['projects', 'pagination']"
aria-label="Project pages"
/>
</template>
React
import Pagination from '@/components/ui/pagination/Pagination'
export default function ProjectPages({ pagination }) {
return (
<Pagination
page={pagination.page}
pages={pagination.totalPages}
only={['projects', 'pagination']}
aria-label="Project pages"
/>
)
}
Svelte
<script>
import Pagination from '$lib/components/ui/pagination/Pagination.svelte'
let { pagination } = $props()
</script>
<Pagination
page={pagination.page}
pages={pagination.totalPages}
only={['projects', 'pagination']}
aria-label="Project pages"
/>
only is optional. Use it when an Inertia partial reload should request only the result and pagination props. Leave it out when changing page should refresh the full page payload.
API
| Input | Default | Purpose |
|---|---|---|
page | required | Current server-provided page. |
pages | required | Total server-provided page count. Pagination renders nothing when this is one. |
only | [] | Optional Inertia partial-reload prop names. |
aria-label | Pagination | Accessible name for the navigation landmark. |
class / className | — | Ordinary Tailwind classes merged on the navigation root. |
| native attributes | — | IDs, test hooks, and other navigation attributes forwarded to the root. |
That is the behavioral API. Change the copied Tailwind classes for a product's visual language instead of adding presentation props.
Styling different products
The installed file is application source, so change its baseline Tailwind once when the whole product needs a different pagination treatment. For a contextual treatment, target the stable data-slot hooks with ordinary Tailwind:
<Pagination
:page="pagination.page"
:pages="pagination.totalPages"
class="**:data-[slot=page]:rounded-none **:data-[slot=page]:border-black [&_[data-slot=page][data-state=current]]:bg-black [&_[data-slot=page][data-state=current]]:text-white"
/>The available hooks are pagination, previous, page, ellipsis, summary, and next; the current page also has data-state="current" and a pending destination has data-pending. These are styling seams, not visual variants—the navigation contract stays the same.
Durable by default
Pagination treats the URL and server response as the record of what the user is viewing:
- changing page uses push history, so Back and Forward return to the pages the user visited;
- the current query and hash are preserved, so search, filters, sorting, lenses, and dashboard context do not disappear;
- page one has the clean canonical URL without
page=1; - modified clicks and opening in a new tab remain normal link behavior;
- unavailable Previous and Next controls are non-interactive text rather than links that lie;
aria-current="page"identifies the current destination;- duplicate activation is ignored while that destination is pending;
- focus is recovered when a server update removes the link that initiated navigation;
- narrow layouts keep only Previous, “Page x of y”, and Next without extra configuration.
Do not persist the current page in local storage. If the list can be shared or revisited, its URL and server props are already the durable state.
Server-list recipe
Slipway-style lists usually combine pagination with search, sorting, filters, and a server response:
<Pagination
:page="pagination.page"
:pages="pagination.totalPages"
:only="['projects', 'pagination', 'filters']"
aria-label="Project pages"
/>Pagination changes only the page query value. Existing values such as q, sort, direction, status, or a selected lens stay in the destination automatically. Applications should reset page to one when a filter changes the result set; the filter action owns that decision.
Responsive and large result sets
At wider sizes, Pagination shows the useful page window plus ellipses. At narrow sizes, it shows the current position between Previous and Next. Both views describe the same server state and use the same destinations.
The component clamps malformed page values to a truthful visible range, but the server should still validate requested pages and return its canonical current page. Rendering that response keeps refresh, sharing, and history correct.
When to use
Use Pagination when a server-backed collection is divided into discrete pages: audit logs, bridge resources, invoices, deployments, search results, or long administrative lists.
When not to use
- Use ordinary links when there are only Previous and Next destinations and no useful page count.
- Use an explicit “Load more” action when users are intentionally accumulating items in one continuous view.
- Use infinite loading only when position is disposable and the application has a deliberate focus and history strategy.
- Do not paginate a short list merely to make a layout look smaller.
Complete framework source
Copy, inspect, and change the complete one-file source for your framework.
Vue source
<script setup>
import { Link, usePage } from '@inertiajs/vue3'
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** Server-provided current page. */
page: { type: [Number, String], required: true },
/** Server-provided total number of pages. */
pages: { type: [Number, String], required: true },
/** Optional Inertia partial-reload prop names. */
only: { type: Array, default: () => [] }
})
const attrs = useAttrs()
const inertiaPage = usePage()
const root = ref()
const pendingPage = ref()
const lastIntent = ref()
const LINK_CLASSES =
'inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center gap-2 rounded-md border border-gray-200 bg-white px-3 text-sm font-medium text-gray-700 no-underline transition-colors hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-900 dark:focus-visible:outline-white'
const CURRENT_CLASSES =
'border-gray-950 bg-gray-950 text-white hover:bg-gray-950 dark:border-white dark:bg-white dark:text-gray-950 dark:hover:bg-white'
const DISABLED_CLASSES =
'inline-flex min-h-11 min-w-11 cursor-not-allowed items-center justify-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-medium text-gray-400 opacity-70 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-600'
function positiveInteger(value, fallback = 1) {
const number = Math.trunc(Number(value))
return Number.isFinite(number) && number > 0 ? number : fallback
}
function pageWindow(current, total) {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1)
const visible = new Set([1, total, current - 1, current, current + 1])
if (current <= 4) [2, 3, 4, 5].forEach((value) => visible.add(value))
if (current >= total - 3) {
;[total - 4, total - 3, total - 2, total - 1].forEach((value) =>
visible.add(value)
)
}
const ordered = [...visible]
.filter((value) => value >= 1 && value <= total)
.sort((a, b) => a - b)
return ordered.flatMap((value, index) => {
const previous = ordered[index - 1]
return index > 0 && value - previous > 1 ? [null, value] : [value]
})
}
function browserUrl() {
if (typeof window === 'undefined') return '/'
return `${window.location.pathname}${window.location.search}${window.location.hash}`
}
function hrefFor(source, target) {
const raw = source || '/'
const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw)
const url = new URL(raw, 'http://klean.invalid')
if (target === 1) url.searchParams.delete('page')
else url.searchParams.set('page', String(target))
return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`
}
const totalPages = computed(() => positiveInteger(props.pages))
const currentPage = computed(() =>
Math.min(positiveInteger(props.page), totalPages.value)
)
const items = computed(() => pageWindow(currentPage.value, totalPages.value))
const currentUrl = computed(() => inertiaPage.url || browserUrl())
const label = computed(() => attrs['aria-label'] || 'Pagination')
const rootAttrs = computed(() => {
const {
class: _class,
'aria-label': _ariaLabel,
'aria-busy': _ariaBusy,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
function isPlainActivation(event) {
return (
(event.button === undefined || event.button === 0) &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
)
}
function rememberIntent(event, target) {
if (!isPlainActivation(event)) return
if (pendingPage.value === target) {
event.preventDefault()
return
}
lastIntent.value = target
}
function start(target) {
pendingPage.value = target
}
function finish(target) {
if (pendingPage.value === target) pendingPage.value = undefined
}
function linkProps(target) {
return {
href: hrefFor(currentUrl.value, target),
only: props.only,
preserveScroll: true,
preserveState: true
}
}
watch(currentPage, async (nextPage, previousPage) => {
if (nextPage === previousPage || lastIntent.value !== nextPage) return
await nextTick()
if (!root.value?.contains(document.activeElement)) {
root.value
?.querySelector(`[data-slot="page"][data-page="${nextPage}"]`)
?.focus({ preventScroll: true })
}
lastIntent.value = undefined
})
</script>
<template>
<nav
v-if="totalPages > 1"
ref="root"
v-bind="rootAttrs"
data-slot="pagination"
:aria-label="label"
:aria-busy="pendingPage ? 'true' : undefined"
:class="twMerge('w-full', attrs.class)"
>
<ul class="flex items-center justify-between gap-2 sm:justify-center">
<li>
<Link
v-if="currentPage > 1"
v-bind="linkProps(currentPage - 1)"
data-slot="previous"
:data-page="currentPage - 1"
:data-pending="pendingPage === currentPage - 1 ? '' : undefined"
:aria-label="`Go to page ${currentPage - 1}`"
:class="LINK_CLASSES"
@click="rememberIntent($event, currentPage - 1)"
@start="start(currentPage - 1)"
@finish="finish(currentPage - 1)"
@cancel="finish(currentPage - 1)"
@error="finish(currentPage - 1)"
>
<svg
aria-hidden="true"
class="size-4"
viewBox="0 0 20 20"
fill="none"
>
<path
d="m12.5 15-5-5 5-5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="hidden sm:inline">Previous</span>
</Link>
<span
v-else
data-slot="previous"
aria-disabled="true"
:class="DISABLED_CLASSES"
>
<svg
aria-hidden="true"
class="size-4"
viewBox="0 0 20 20"
fill="none"
>
<path
d="m12.5 15-5-5 5-5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="hidden sm:inline">Previous</span>
</span>
</li>
<li class="sm:hidden">
<span
data-slot="summary"
aria-current="page"
class="px-2 text-sm text-gray-600 tabular-nums dark:text-gray-300"
>
Page {{ currentPage }} of {{ totalPages }}
</span>
</li>
<li
v-for="(item, index) in items"
:key="item ?? `ellipsis-${index}`"
class="hidden sm:block"
>
<span
v-if="item === null"
data-slot="ellipsis"
class="inline-flex min-h-11 min-w-8 items-center justify-center text-sm text-gray-400 dark:text-gray-500"
>
<span aria-hidden="true">…</span>
<span class="sr-only">More pages</span>
</span>
<Link
v-else
v-bind="linkProps(item)"
data-slot="page"
:data-page="item"
:data-state="item === currentPage ? 'current' : undefined"
:data-pending="pendingPage === item ? '' : undefined"
:aria-current="item === currentPage ? 'page' : undefined"
:aria-label="
item === currentPage
? `Page ${item}, current page`
: `Go to page ${item}`
"
:class="
twMerge(LINK_CLASSES, item === currentPage && CURRENT_CLASSES)
"
@click="rememberIntent($event, item)"
@start="start(item)"
@finish="finish(item)"
@cancel="finish(item)"
@error="finish(item)"
>
{{ item }}
</Link>
</li>
<li>
<Link
v-if="currentPage < totalPages"
v-bind="linkProps(currentPage + 1)"
data-slot="next"
:data-page="currentPage + 1"
:data-pending="pendingPage === currentPage + 1 ? '' : undefined"
:aria-label="`Go to page ${currentPage + 1}`"
:class="LINK_CLASSES"
@click="rememberIntent($event, currentPage + 1)"
@start="start(currentPage + 1)"
@finish="finish(currentPage + 1)"
@cancel="finish(currentPage + 1)"
@error="finish(currentPage + 1)"
>
<span class="hidden sm:inline">Next</span>
<svg
aria-hidden="true"
class="size-4"
viewBox="0 0 20 20"
fill="none"
>
<path
d="m7.5 5 5 5-5 5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Link>
<span
v-else
data-slot="next"
aria-disabled="true"
:class="DISABLED_CLASSES"
>
<span class="hidden sm:inline">Next</span>
<svg
aria-hidden="true"
class="size-4"
viewBox="0 0 20 20"
fill="none"
>
<path
d="m7.5 5 5 5-5 5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</li>
</ul>
</nav>
</template>
React source
import { Link, usePage } from '@inertiajs/react'
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'
const LINK_CLASSES =
'inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center gap-2 rounded-md border border-gray-200 bg-white px-3 text-sm font-medium text-gray-700 no-underline transition-colors hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-900 dark:focus-visible:outline-white'
const CURRENT_CLASSES =
'border-gray-950 bg-gray-950 text-white hover:bg-gray-950 dark:border-white dark:bg-white dark:text-gray-950 dark:hover:bg-white'
const DISABLED_CLASSES =
'inline-flex min-h-11 min-w-11 cursor-not-allowed items-center justify-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-medium text-gray-400 opacity-70 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-600'
function assignRef(ref, value) {
if (typeof ref === 'function') ref(value)
else if (ref) ref.current = value
}
function positiveInteger(value, fallback = 1) {
const number = Math.trunc(Number(value))
return Number.isFinite(number) && number > 0 ? number : fallback
}
function pageWindow(current, total) {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1)
const visible = new Set([1, total, current - 1, current, current + 1])
if (current <= 4) [2, 3, 4, 5].forEach((value) => visible.add(value))
if (current >= total - 3) {
;[total - 4, total - 3, total - 2, total - 1].forEach((value) =>
visible.add(value)
)
}
const ordered = [...visible]
.filter((value) => value >= 1 && value <= total)
.sort((a, b) => a - b)
return ordered.flatMap((value, index) => {
const previous = ordered[index - 1]
return index > 0 && value - previous > 1 ? [null, value] : [value]
})
}
function browserUrl() {
if (typeof window === 'undefined') return '/'
return `${window.location.pathname}${window.location.search}${window.location.hash}`
}
function hrefFor(source, target) {
const raw = source || '/'
const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw)
const url = new URL(raw, 'http://klean.invalid')
if (target === 1) url.searchParams.delete('page')
else url.searchParams.set('page', String(target))
return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`
}
function useInertiaUrl() {
try {
return usePage().url
} catch (error) {
if (!String(error).includes('usePage must be used within')) throw error
return undefined
}
}
const Pagination = forwardRef(function Pagination(
{
page,
pages,
only = [],
className,
'aria-label': ariaLabel = 'Pagination',
'aria-busy': _ariaBusy,
'data-slot': _dataSlot,
...navProps
},
forwardedRef
) {
const rootRef = useRef(null)
const lastIntentRef = useRef()
const [pendingPage, setPendingPage] = useState()
const inertiaUrl = useInertiaUrl()
const totalPages = positiveInteger(pages)
const currentPage = Math.min(positiveInteger(page), totalPages)
const items = pageWindow(currentPage, totalPages)
const currentUrl = inertiaUrl || browserUrl()
useLayoutEffect(() => {
if (lastIntentRef.current !== currentPage) return
if (!rootRef.current?.contains(document.activeElement)) {
rootRef.current
?.querySelector(`[data-slot="page"][data-page="${currentPage}"]`)
?.focus({ preventScroll: true })
}
lastIntentRef.current = undefined
}, [currentPage])
if (totalPages <= 1) return null
function setRoot(node) {
rootRef.current = node
assignRef(forwardedRef, node)
}
function isPlainActivation(event) {
return (
(event.button === undefined || event.button === 0) &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
)
}
function rememberIntent(event, target) {
if (!isPlainActivation(event)) return
if (pendingPage === target) {
event.preventDefault()
return
}
lastIntentRef.current = target
}
function finish(target) {
setPendingPage((pending) => (pending === target ? undefined : pending))
}
function linkProps(target) {
return {
href: hrefFor(currentUrl, target),
only,
preserveScroll: true,
preserveState: true,
onClick: (event) => rememberIntent(event, target),
onStart: () => setPendingPage(target),
onFinish: () => finish(target),
onCancel: () => finish(target),
onError: () => finish(target)
}
}
function Chevron({ direction }) {
const path =
direction === 'previous' ? 'm12.5 15-5-5 5-5' : 'm7.5 5 5 5-5 5'
return (
<svg
aria-hidden="true"
className="size-4"
viewBox="0 0 20 20"
fill="none"
>
<path
d={path}
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
return (
<nav
{...navProps}
ref={setRoot}
data-slot="pagination"
aria-label={ariaLabel}
aria-busy={pendingPage ? 'true' : undefined}
className={twMerge('w-full', className)}
>
<ul className="flex items-center justify-between gap-2 sm:justify-center">
<li>
{currentPage > 1 ? (
<Link
{...linkProps(currentPage - 1)}
data-slot="previous"
data-page={currentPage - 1}
data-pending={pendingPage === currentPage - 1 ? '' : undefined}
aria-label={`Go to page ${currentPage - 1}`}
className={LINK_CLASSES}
>
<Chevron direction="previous" />
<span className="hidden sm:inline">Previous</span>
</Link>
) : (
<span
data-slot="previous"
aria-disabled="true"
className={DISABLED_CLASSES}
>
<Chevron direction="previous" />
<span className="hidden sm:inline">Previous</span>
</span>
)}
</li>
<li className="sm:hidden">
<span
data-slot="summary"
aria-current="page"
className="px-2 text-sm text-gray-600 tabular-nums dark:text-gray-300"
>
Page {currentPage} of {totalPages}
</span>
</li>
{items.map((item, index) => (
<li key={item ?? `ellipsis-${index}`} className="hidden sm:block">
{item === null ? (
<span
data-slot="ellipsis"
className="inline-flex min-h-11 min-w-8 items-center justify-center text-sm text-gray-400 dark:text-gray-500"
>
<span aria-hidden="true">…</span>
<span className="sr-only">More pages</span>
</span>
) : (
<Link
{...linkProps(item)}
data-slot="page"
data-page={item}
data-state={item === currentPage ? 'current' : undefined}
data-pending={pendingPage === item ? '' : undefined}
aria-current={item === currentPage ? 'page' : undefined}
aria-label={
item === currentPage
? `Page ${item}, current page`
: `Go to page ${item}`
}
className={twMerge(
LINK_CLASSES,
item === currentPage && CURRENT_CLASSES
)}
>
{item}
</Link>
)}
</li>
))}
<li>
{currentPage < totalPages ? (
<Link
{...linkProps(currentPage + 1)}
data-slot="next"
data-page={currentPage + 1}
data-pending={pendingPage === currentPage + 1 ? '' : undefined}
aria-label={`Go to page ${currentPage + 1}`}
className={LINK_CLASSES}
>
<span className="hidden sm:inline">Next</span>
<Chevron direction="next" />
</Link>
) : (
<span
data-slot="next"
aria-disabled="true"
className={DISABLED_CLASSES}
>
<span className="hidden sm:inline">Next</span>
<Chevron direction="next" />
</span>
)}
</li>
</ul>
</nav>
)
})
export default Pagination
Svelte source
<script>
import { Link, usePage } from "@inertiajs/svelte";
import { twMerge } from "tailwind-merge";
let {
page,
pages,
only = [],
class: className,
"aria-label": ariaLabel = "Pagination",
"aria-busy": _ariaBusy,
"data-slot": _dataSlot,
...navProps
} = $props();
const inertiaPage = usePage();
const LINK_CLASSES =
"inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center gap-2 rounded-md border border-gray-200 bg-white px-3 text-sm font-medium text-gray-700 no-underline transition-colors hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-900 dark:focus-visible:outline-white";
const CURRENT_CLASSES =
"border-gray-950 bg-gray-950 text-white hover:bg-gray-950 dark:border-white dark:bg-white dark:text-gray-950 dark:hover:bg-white";
const DISABLED_CLASSES =
"inline-flex min-h-11 min-w-11 cursor-not-allowed items-center justify-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-medium text-gray-400 opacity-70 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-600";
let rootElement = $state();
let pendingPage = $state();
let lastIntent;
let previousPage;
function positiveInteger(value, fallback = 1) {
const number = Math.trunc(Number(value));
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function pageWindow(current, total) {
if (total <= 7)
return Array.from({ length: total }, (_, index) => index + 1);
const visible = new Set([1, total, current - 1, current, current + 1]);
if (current <= 4) [2, 3, 4, 5].forEach((value) => visible.add(value));
if (current >= total - 3) {
[total - 4, total - 3, total - 2, total - 1].forEach((value) =>
visible.add(value),
);
}
const ordered = [...visible]
.filter((value) => value >= 1 && value <= total)
.sort((a, b) => a - b);
return ordered.flatMap((value, index) => {
const previous = ordered[index - 1];
return index > 0 && value - previous > 1 ? [null, value] : [value];
});
}
function browserUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
function hrefFor(source, target) {
const raw = source || "/";
const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw);
const url = new URL(raw, "http://klean.invalid");
if (target === 1) url.searchParams.delete("page");
else url.searchParams.set("page", String(target));
return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`;
}
let totalPages = $derived(positiveInteger(pages));
let currentPage = $derived(Math.min(positiveInteger(page), totalPages));
let items = $derived(pageWindow(currentPage, totalPages));
let currentUrl = $derived(inertiaPage.url || browserUrl());
function isPlainActivation(event) {
return (
(event.button === undefined || event.button === 0) &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
function rememberIntent(event, target) {
if (!isPlainActivation(event)) return;
if (pendingPage === target) {
event.preventDefault();
return;
}
lastIntent = target;
}
function finish(target) {
if (pendingPage === target) pendingPage = undefined;
}
function linkProps(target) {
return {
href: hrefFor(currentUrl, target),
only,
preserveScroll: true,
preserveState: true,
};
}
$effect(() => {
const nextPage = currentPage;
if (
previousPage !== undefined &&
previousPage !== nextPage &&
lastIntent === nextPage
) {
queueMicrotask(() => {
if (typeof document === "undefined") return;
if (!rootElement?.contains(document.activeElement)) {
rootElement
?.querySelector(`[data-slot="page"][data-page="${nextPage}"]`)
?.focus({ preventScroll: true });
}
lastIntent = undefined;
});
}
previousPage = nextPage;
});
</script>
{#snippet Chevron(direction)}
<svg aria-hidden="true" class="size-4" viewBox="0 0 20 20" fill="none">
<path
d={direction === "previous" ? "m12.5 15-5-5 5-5" : "m7.5 5 5 5-5 5"}
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/snippet}
{#if totalPages > 1}
<nav
{...navProps}
bind:this={rootElement}
data-slot="pagination"
aria-label={ariaLabel}
aria-busy={pendingPage ? "true" : undefined}
class={twMerge("w-full", className)}
>
<ul class="flex items-center justify-between gap-2 sm:justify-center">
<li>
{#if currentPage > 1}
<Link
{...linkProps(currentPage - 1)}
data-slot="previous"
data-page={currentPage - 1}
data-pending={pendingPage === currentPage - 1 ? "" : undefined}
aria-label={`Go to page ${currentPage - 1}`}
class={LINK_CLASSES}
onclick={(event) => rememberIntent(event, currentPage - 1)}
onstart={() => (pendingPage = currentPage - 1)}
onfinish={() => finish(currentPage - 1)}
oncancel={() => finish(currentPage - 1)}
onerror={() => finish(currentPage - 1)}
>
{@render Chevron("previous")}
<span class="hidden sm:inline">Previous</span>
</Link>
{:else}
<span
data-slot="previous"
aria-disabled="true"
class={DISABLED_CLASSES}
>
{@render Chevron("previous")}
<span class="hidden sm:inline">Previous</span>
</span>
{/if}
</li>
<li class="sm:hidden">
<span
data-slot="summary"
aria-current="page"
class="px-2 text-sm text-gray-600 tabular-nums dark:text-gray-300"
>
Page {currentPage} of {totalPages}
</span>
</li>
{#each items as item, index (item ?? `ellipsis-${index}`)}
<li class="hidden sm:block">
{#if item === null}
<span
data-slot="ellipsis"
class="inline-flex min-h-11 min-w-8 items-center justify-center text-sm text-gray-400 dark:text-gray-500"
>
<span aria-hidden="true">…</span>
<span class="sr-only">More pages</span>
</span>
{:else}
<Link
{...linkProps(item)}
data-slot="page"
data-page={item}
data-state={item === currentPage ? "current" : undefined}
data-pending={pendingPage === item ? "" : undefined}
aria-current={item === currentPage ? "page" : undefined}
aria-label={item === currentPage
? `Page ${item}, current page`
: `Go to page ${item}`}
class={twMerge(
LINK_CLASSES,
item === currentPage && CURRENT_CLASSES,
)}
onclick={(event) => rememberIntent(event, item)}
onstart={() => (pendingPage = item)}
onfinish={() => finish(item)}
oncancel={() => finish(item)}
onerror={() => finish(item)}
>
{item}
</Link>
{/if}
</li>
{/each}
<li>
{#if currentPage < totalPages}
<Link
{...linkProps(currentPage + 1)}
data-slot="next"
data-page={currentPage + 1}
data-pending={pendingPage === currentPage + 1 ? "" : undefined}
aria-label={`Go to page ${currentPage + 1}`}
class={LINK_CLASSES}
onclick={(event) => rememberIntent(event, currentPage + 1)}
onstart={() => (pendingPage = currentPage + 1)}
onfinish={() => finish(currentPage + 1)}
oncancel={() => finish(currentPage + 1)}
onerror={() => finish(currentPage + 1)}
>
<span class="hidden sm:inline">Next</span>
{@render Chevron("next")}
</Link>
{:else}
<span data-slot="next" aria-disabled="true" class={DISABLED_CLASSES}>
<span class="hidden sm:inline">Next</span>
{@render Chevron("next")}
</span>
{/if}
</li>
</ul>
</nav>
{/if}
Related components
- Table — supplies native row and column structure for paginated results.
- Input — provides a visible search field while the URL owns the query.
- Select — handles fixed filters or an application-owned page-size control.
- Combobox — handles searchable filters for larger option sets.
- Spinner — can identify work elsewhere in the result region when a page request takes long enough to warrant it.