Pagination
A page switcher built from real buttons under a <nav>. The range is computed
from the current page and a siblings count, so the control keeps a stable
width while the numbers in the middle slide over instead of pushing the layout
around.
The active page is marked twice: aria-current="page" on its button for
assistive technology, and a shared-layout pill that travels from the old page to
the new one. Because the pill is one element with a fixed layout-id rather
than a background on each button, the movement is continuous even when the
middle of the range renumbers.
Usage
Pass totalPages, then bind v-model:page to keep the parent in charge. Leave
page off and the component keeps its own copy; update:page fires either way,
so it also works as a plain event source.
Page 1 of 24
<template>
<div class="flex w-full flex-col items-center gap-4 py-10">
<MPagination
v-model:page="page"
:total-pages="24"
/>
<p class="text-sm text-muted">
Page {{ page }} of 24
</p>
</div>
</template>
<script setup lang="ts">
const page = ref(1)
</script>
Customize
siblings sets how many pages sit on each side of the current one before the
range gives up and inserts an ellipsis. At 0 you get the first page, the
current page and the last page; at 2 the middle window is wider and the
control can go longer between jumps. totalPages drives the whole range, and the
Previous and Next labels drop away below sm while the chevrons stay.
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
page | number | - | Current page, 1-indexed. Leave it out to let the component own the value. The
displayed page is clamped to the 1..totalPages range, so an out-of-range
prop cannot put the control in an impossible state. |
totalPagesrequired | number | - | Total number of pages. Values below 1 are treated as 1. |
siblings | number | 1 | Pages shown on each side of the current one before an ellipsis replaces the gap. Fractional values are floored. |
ariaLabel | string | 'Pagination' | Accessible name for the <nav> landmark. Set it when a page has more than one
paginated region. |
class | string | - | Extra classes for the root <nav>. |
ui | Record<string, any> | - | Per-slot classes merged over the theme defaults. See UI for the slot list. |
Events
| Event | Payload | Description |
|---|---|---|
update:page | (page: number) => void | The next page, clamped to the valid range. It is skipped when the target
equals the current page, so clicking the active number or a disabled arrow
does not emit. Powers v-model:page. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.pagination in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | The <nav> wrapper. |
list | - | The horizontal <ul>. |
item | - | One <li>, shared by the arrows and the numbers. |
navButton | - | The Previous and Next buttons. |
navLabel | - | The Previous / Next text, hidden below sm. |
icon | - | The chevron inside an arrow button. |
ellipsis | - | The non-interactive ... cell. |
pageButton | - | A numbered page button. |
indicator | - | The shared pill behind the active number. |
pageLabel | - | The number inside a page button, layered above the pill. |
Accessibility
- The control is a
<nav>with a configurable accessible name, containing a<ul>of<li>entries. Every interactive part is a native<button type="button">, so Enter and Space work without custom key handling. - The current page carries
aria-current="page"and is still focusable, so a keyboard user can land on it and read where they are. - Each number is labelled
Go to page N; the arrows are labelledGo to previous pageandGo to next pagerather than relying on the chevron. The ellipsis isaria-hidden. - Disabled arrows use the native
disabledattribute, which removes them from the tab order and dims them through the theme'sdisabled:opacity-50. - Under
prefers-reduced-motionthe rows and the pill keep their hidden initial state but every transition collapses to{ duration: 0 }. The pill still lands on the new page; it simply does not travel.
Performance
buildPageRangeruns inside acomputedkeyed on page, total and siblings. It walks at mostsiblings * 2 + 5slots, so a change costs the same whether there are 20 pages or 2,000.- The active pill is a single shared-layout element. Moving it is a FLIP pass
that writes
transform; no layout is read in your code and no observer is attached. - Every visible page is its own
motion.likeyed by its number, so changing the page updates a handful of nodes rather than remounting the list. The stagger isindex * 0.03with no timers. - The spring is a module-level constant, and the reduced-motion branch returns a
shared frozen
{ duration: 0 }. The stagger is a shallow copy addingdelay, rebuilt on list re-render rather than per frame. - There are no watchers, intervals or
requestAnimationFrameloops.