Navigate pages with buttons, links, or your router.

Console
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon, EllipsisIcon } from "@lucide/vue"
import {
  Pagination,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationList,
  PaginationNext,
  PaginationPrevious,
} from "@ui-sdk/vue/pagination"
import { useLog } from "./log"

const log = useLog()
</script>

<template>
  <Pagination
    aria-label="Pagination"
    :page-count="10"
    @page-change="(page, details) => log(`Page ${page} (${details.reason})`)"
  >
    <PaginationList v-slot="{ state }">
      <PaginationItem>
        <PaginationPrevious data-variant="ghost">
          <ChevronLeftIcon />
          <span data-slot="visually-hidden">Previous</span>
        </PaginationPrevious>
      </PaginationItem>
      <PaginationItem v-for="item in state.items" :key="item">
        <PaginationLink
          v-if="typeof item === 'number'"
          data-variant="ghost"
          :page="item"
        />
        <PaginationEllipsis v-else>
          <EllipsisIcon />
        </PaginationEllipsis>
      </PaginationItem>
      <PaginationItem>
        <PaginationNext data-variant="ghost">
          <span data-slot="visually-hidden">Next</span>
          <ChevronRightIcon />
        </PaginationNext>
      </PaginationItem>
    </PaginationList>
  </Pagination>
</template>
Choose a page, or use Previous and Next. The console logs each page and its reason, and the step at either end stays focusable as a button.

Installation

npm install @ui-sdk/vue @ui-sdk/core

Composition

Use the following composition to build a Pagination:

Pagination
└── PaginationList
    ├── PaginationItem
    │   └── PaginationPrevious
    ├── PaginationItem
    │   └── PaginationLink
    ├── PaginationItem
    │   └── PaginationEllipsis
    ├── PaginationItem
    │   └── PaginationLink
    └── PaginationItem
        └── PaginationNext

Name the navigation with aria-label, then put the page links and steps in a list.

The list's default slot receives its state, whose items contains page numbers and the strings "ellipsis-start" and "ellipsis-end". Render a link for each number and an ellipsis for each gap. Key entries by the item itself so a page link keeps its element as the range moves.

PaginationLink shows its page number unless you supply children. The current link gets aria-current="page" and data-current. PaginationEllipsis shows “…” and is hidden from screen readers.

Give Previous, Next, First, and Last text or an aria-label. At an end, a step gets aria-disabled="true" and data-disabled and stops changing the page. As a button, it stays in the tab order.

Usage

Import the parts and put them together:

<script setup lang="ts">
import {
  Pagination,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationList,
  PaginationNext,
  PaginationPrevious,
} from "@ui-sdk/vue/pagination"
</script>

<template>
  <Pagination aria-label="Pagination" :page-count="10">
    <PaginationList v-slot="{ state }">
      <PaginationItem>
        <PaginationPrevious>Previous</PaginationPrevious>
      </PaginationItem>
      <PaginationItem v-for="item in state.items" :key="item">
        <PaginationLink v-if="typeof item === 'number'" :page="item" />
        <PaginationEllipsis v-else />
      </PaginationItem>
      <PaginationItem>
        <PaginationNext>Next</PaginationNext>
      </PaginationItem>
    </PaginationList>
  </Pagination>
</template>

Total and Page Size

Instead of page-count, pass total, how many items there are, and page-size, how many fit on a page (10 by default). Pagination calculates the page count, with a minimum of one. This example resets to page 1 when the page size changes.

A page past the end, like after the count shrinks, reads as the last page.

Console
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon, EllipsisIcon } from "@lucide/vue"
import {
  Pagination,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationList,
  PaginationNext,
  PaginationPrevious,
} from "@ui-sdk/vue/pagination"
import { ref } from "vue"
import { useLog } from "./log"

const log = useLog()
const page = ref(2)
const pageSize = ref(20)
const total = 95

function onPageSizeChange(event: Event) {
  const { value } = event.target as HTMLSelectElement
  pageSize.value = Number(value)
  page.value = 1
  log(`${value} rows per page`)
}
</script>

<template>
  <div data-slot="stack">
    <div data-slot="row">
      <p>
        Showing {{ (page - 1) * pageSize + 1 }}–{{
          Math.min(page * pageSize, total)
        }}
        of {{ total }}
      </p>
      <label data-slot="label">
        Rows per page
        <select :value="pageSize" @change="onPageSizeChange">
          <option :value="10">10</option>
          <option :value="20">20</option>
          <option :value="50">50</option>
        </select>
      </label>
    </div>
    <Pagination
      v-model:page="page"
      aria-label="Pagination"
      :total="total"
      :page-size="pageSize"
      @page-change="(next) => log(`Page ${next}`)"
    >
      <PaginationList v-slot="{ state }">
        <PaginationItem>
          <PaginationPrevious data-variant="ghost">
            <ChevronLeftIcon />
            <span data-slot="visually-hidden">Previous</span>
          </PaginationPrevious>
        </PaginationItem>
        <PaginationItem v-for="item in state.items" :key="item">
          <PaginationLink
            v-if="typeof item === 'number'"
            data-variant="ghost"
            :page="item"
          />
          <PaginationEllipsis v-else>
            <EllipsisIcon />
          </PaginationEllipsis>
        </PaginationItem>
        <PaginationItem>
          <PaginationNext data-variant="ghost">
            <span data-slot="visually-hidden">Next</span>
            <ChevronRightIcon />
          </PaginationNext>
        </PaginationItem>
      </PaginationList>
    </Pagination>
  </div>
</template>
Choose a page, then change Rows per page. The console logs each change, and a new size starts again at page 1.

Simple

With only a few pages, the range has no gaps, so you can write the links out and leave the steps off.

<Pagination aria-label="Pagination" :page-count="5">
  <PaginationList>
    <PaginationItem v-for="page in [1, 2, 3, 4, 5]" :key="page">
      <PaginationLink :page="page" />
    </PaginationItem>
  </PaginationList>
</Pagination>

Pass get-href to map each page to a URL. Page links and steps then render as anchors.

<Pagination
  aria-label="Pagination"
  :page-count="10"
  :get-href="(page) => `/posts?page=${page}`"
>
  ...
</Pagination>

Server-rendered anchors work before JavaScript loads. A normal click changes the page and follows the URL. Modifier clicks and middle clicks leave pagination state alone so the browser can open the link elsewhere.

A step at its end loses its href and leaves the tab order, but keeps focus if it already had it. During navigation, its original href stays in place so Next cannot accidentally follow the next page's URL.

Render your router's link to page on the client, in the render slot. With Vue Router, use RouterLink's custom form and bind the part's props to your own <a>, then its navigate after them. The pagination's click handler runs first: when a change is canceled or a step is at its end, it calls preventDefault(), and the router leaves the link alone.

Read the page from the URL, like your router's search params, so the route controls the pagination.

<script setup lang="ts">
const route = useRoute()
const page = computed(() => Number(route.query.page ?? 1))
</script>

<template>
  <Pagination
    aria-label="Pagination"
    :page-count="10"
    :page="page"
    :get-href="(page) => `?page=${page}`"
  >
    <PaginationList v-slot="{ state }">
      <PaginationItem v-for="item in state.items" :key="item">
        <PaginationLink v-if="typeof item === 'number'" :page="item">
          <template #render="{ props, children }">
            <RouterLink v-slot="{ href, navigate }" :to="props.href" custom>
              <a v-bind="props" :href="href" @click="navigate"
                ><component :is="children"
              /></a>
            </RouterLink>
          </template>
        </PaginationLink>
        <PaginationEllipsis v-else />
      </PaginationItem>
    </PaginationList>
  </Pagination>
</template>

Controlled

Bind v-model:page to control the pagination, and listen to @page-change for the reason.

<script setup lang="ts">
const page = ref(1)
</script>

<template>
  <Pagination v-model:page="page" aria-label="Pagination" :page-count="10">
    ...
  </Pagination>
</template>

Every change carries a reason: item-press for a page, previous-press, next-press, first-press or last-press for a step, and imperative from a controller. Call details.cancel() to keep the page, like while a form has unsaved changes. A canceled change also stops a link from navigating.

Default Page

default-page is the page it starts on, when uncontrolled. It's 1 by default.

<Pagination aria-label="Pagination" :page-count="10" :default-page="5">
  ...
</Pagination>

Siblings

siblings is how many pages show on each side of the current one, 1 by default. Near an end, the window slides over instead of shrinking, keeping the range length stable. A gap of one page shows that page instead of an ellipsis.

<Pagination
  aria-label="Pagination"
  :page-count="20"
  :default-page="10"
  :siblings="2"
>
  ...
</Pagination>

Boundaries

boundaries is how many pages always show at each end, 1 by default.

<Pagination
  aria-label="Pagination"
  :page-count="20"
  :default-page="10"
  :boundaries="2"
>
  ...
</Pagination>

Paging from Anywhere

A controller, from createPaginationController(), pages the pagination from anywhere, like buttons elsewhere on the page or a keyboard shortcut, without controlling it. Pass it as controller.

<script setup lang="ts">
const pagination = createPaginationController()
</script>

<template>
  <button @click="pagination.next()">Next Page</button>
  <Pagination aria-label="Pagination" :page-count="10" :controller="pagination">
    ...
  </Pagination>
</template>

Your Own Markup

paginationRange({ page, pageCount, siblings, boundaries }) is the range the parts use, as a pure function. It runs anywhere, like on the server, in an email or for a UI that isn't a list of pages. Import it from @ui-sdk/core/pagination. Here it keeps a strip of slide dots the same length however many slides there are.

<script setup lang="ts">
import { paginationRange } from "@ui-sdk/core/pagination"

const items = computed(() =>
  paginationRange({
    page: slide.value,
    pageCount: 20,
    siblings: 2,
    boundaries: 1,
  })
)
</script>

<template>
  <div role="group" aria-label="Slides">
    <template v-for="item in items" :key="item">
      <button
        v-if="typeof item === 'number'"
        :aria-label="`Slide ${item}`"
        :aria-current="item === slide ? 'true' : undefined"
        @click="slide = item"
      />
      <span v-else aria-hidden="true" />
    </template>
  </div>
</template>

Custom Rendering

Every part takes a render slot, which receives the props to bind and the part's state: whether a link is current, and for a step, the page it goes to and whether it's disabled. The default slot receives the state too.

<script setup lang="ts">
import { ArrowLeftIcon, ArrowRightIcon } from "@lucide/vue"
import {
  Pagination,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationList,
  PaginationNext,
  PaginationPrevious,
} from "@ui-sdk/vue/pagination"
</script>

<template>
  <Pagination aria-label="Pagination" :page-count="10">
    <PaginationList v-slot="{ state }">
      <PaginationItem>
        <PaginationPrevious aria-label="Previous page">
          <template #render="{ props, state: step }">
            <button
              v-bind="props"
              :title="step.disabled ? undefined : `Go to page ${step.page}`"
            >
              <ArrowLeftIcon aria-hidden="true" />
              <span v-if="!step.disabled" aria-hidden="true">{{
                step.page
              }}</span>
            </button>
          </template>
        </PaginationPrevious>
      </PaginationItem>
      <PaginationItem v-for="item in state.items" :key="item">
        <PaginationLink
          v-if="typeof item === 'number'"
          v-slot="{ state: link }"
          :page="item"
        >
          {{ link.current ? `Page ${link.page}` : link.page }}
        </PaginationLink>
        <PaginationEllipsis v-else />
      </PaginationItem>
      <PaginationItem>
        <PaginationNext aria-label="Next page">
          <template #render="{ props, state: step }">
            <button
              v-bind="props"
              :title="step.disabled ? undefined : `Go to page ${step.page}`"
            >
              <span v-if="!step.disabled" aria-hidden="true">{{
                step.page
              }}</span>
              <ArrowRightIcon aria-hidden="true" />
            </button>
          </template>
        </PaginationNext>
      </PaginationItem>
    </PaginationList>
  </Pagination>
</template>
Choose a page. The current page widens to say Page 3, and each arrow shows the page it goes to.

Styling

Parts are unstyled. Style them through the attributes they render, which are the same in every framework: aria-current="page" or data-current on the current page's link, and aria-disabled or data-disabled on a step at its end.

Each part has a data-slot, such as pagination-link, and its state attributes are listed under Data Attributes. The root, list, items and ellipsis have none.

Data Attributes

[data-slot="pagination-link"]

AttributeWhat it means
data-currentPresent on the current page's link, beside aria-current="page".

PaginationPrevious

[data-slot="pagination-previous"]

AttributeWhat it means
data-disabledPresent on a step at the end it goes to, beside aria-disabled. It stays focusable and does nothing.

PaginationNext

[data-slot="pagination-next"]

AttributeWhat it means
data-disabledPresent on a step at the end it goes to, beside aria-disabled. It stays focusable and does nothing.

PaginationFirst

[data-slot="pagination-first"]

AttributeWhat it means
data-disabledPresent on a step at the end it goes to, beside aria-disabled. It stays focusable and does nothing.

PaginationLast

[data-slot="pagination-last"]

AttributeWhat it means
data-disabledPresent on a step at the end it goes to, beside aria-disabled. It stays focusable and does nothing.

API Reference

Props are the same as below, and callbacks are events.

Pagination

page?number
The current page, from 1. Pass it to control the pagination.
defaultPage?number
The page it starts on, when uncontrolled.
@page-change((page: number, details: ChangeDetails<PaginationPageChangeReason>) => void)
Runs when the page wants to change: a page link ("item-press"), the Previous, Next, First or Last part, or a controller ("imperative"). details.cancel() keeps the page, and stops a link from navigating.
pageCount?number
How many pages there are. Or pass total and pageSize.
total?number
How many items there are, for pageCount from pageSize.
pageSize?number
How many items fit on a page, with total.
siblings?number
How many pages show on each side of the current one.
boundaries?number
How many pages always show at each end.
getHref?((page: number) => string)
The URL of a page. With it, parts are links: they navigate, open in a new tab with a modifier key, and work before JavaScript loads. Without it, they're buttons.
controller?object
Pages it from anywhere. Create one with createPaginationController().
debug?boolean
Logs every state change with its reason to the console, in development builds.
pagenumber
The page the link goes to, from 1.

PaginationList, PaginationItem, PaginationPrevious, PaginationNext, PaginationFirst, PaginationLast and PaginationEllipsis take no props of their own.

State

What render functions, state-based class names and slots receive, per part.

Pagination and PaginationList:

pagenumber
The current page, from 1.
pageCountnumber
How many pages there are.
itemsPaginationRangeItem[]
The pages to show, with an ellipsis for each gap. Render a PaginationLink or a PaginationEllipsis for each.

PaginationLink:

pagenumber
The page it goes to, from its page prop.
currentboolean
Whether it's the current page.

PaginationPrevious, PaginationNext, PaginationFirst and PaginationLast:

pagenumber
The page it goes to.
disabledboolean
Whether it's at the end it goes to, so it does nothing.

Reasons

details.reason says why the page changed.

item-pressMouseEvent | KeyboardEvent
A member of a collection was chosen: a menu item, a tab, a radio or a checkbox item.
previous-pressMouseEvent | KeyboardEvent
A pagination's or a carousel's Previous part was pressed.
next-pressMouseEvent | KeyboardEvent
A pagination's or a carousel's Next part was pressed.
first-pressMouseEvent | KeyboardEvent
A pagination's First part was pressed.
last-pressMouseEvent | KeyboardEvent
A pagination's Last part was pressed.
imperativenone
A controller or api call, or a property set on a web component.

Controller

Create one with createPaginationController() and pass it as controller.

Every write method takes an optional { reason, event }, passed to @page-change, and returns false when the change is canceled or there's nowhere to go.

getPage()() => number
The current page, from 1.
getPageCount()() => number
How many pages there are.
setPage(page, options?)(page: number, options?: PaginationChangeOptions | undefined) => boolean
Goes to a page, clamped to the range. Returns false when canceled or already there.
next(options?)(options?: PaginationChangeOptions | undefined) => boolean
Goes to the next page. Returns false on the last page.
previous(options?)(options?: PaginationChangeOptions | undefined) => boolean
Goes to the previous page. Returns false on the first page.
first(options?)(options?: PaginationChangeOptions | undefined) => boolean
Goes to the first page.
last(options?)(options?: PaginationChangeOptions | undefined) => boolean
Goes to the last page.

Events

Events get the new page first, then the details. v-model only updates when @page-change wasn't canceled.

BindingOnReceives
v-model:pagePaginationThe current page
@page-changePagination(page, details): the new page, and the reason

Accessibility

There's no WAI-ARIA pattern for pagination. It follows the common practice: a navigation landmark named with aria-label, a list of links or buttons, and aria-current="page" on the current one. The list keeps the list role even when styled without bullets, which Safari would otherwise drop.

Focus

Steps rendered as buttons stay focusable at the ends. A clicked page link keeps focus as the range moves; the primitive also tries to restore focus to that page or step if rendering replaces its element. Key range entries by their value rather than their index.

Tested Behavior

Every line below is an automated test.

  • The pagination is a named navigation landmark with a list of pages.
  • The first page is current to start with, or the default page.
  • Pressing a page goes there.
  • Enter on a focused page goes there.
  • Space on a focused page goes there.
  • Next and Previous step one page.
  • First and Last go to the ends.
  • First and Previous say they're disabled on the first page, and do nothing.
  • Next and Last say they're disabled on the last page, and do nothing.
  • A single page has nothing to step to.
  • Siblings and boundaries widen the range.
  • Ellipses are hidden from screen readers.
  • With getHref, pages and steps are links to their pages.
  • A link at an end has no href and says it's disabled.
  • Pressing a link goes to its page and follows it.
  • A change can be canceled.
  • A canceled change stops a link from navigating.
  • The controller pages it from outside.
  • A controlled pagination follows its owner. React and Vue only.

Runs in React, Vue and web components.

Beyond the baseline:

  • Pressing Next onto the last page keeps focus on Next.
  • Pressing Previous onto the first page keeps focus on Previous.
  • Paging through every page with the keyboard never loses focus.
  • Pressing a page keeps focus on it while the range moves around it.
  • Focus comes back to the same part when the list renders again from scratch. React and Vue only.
  • The list has the same number of entries on every page, so nothing shifts under the pointer.
  • A gap of one page shows that page instead of an ellipsis.
  • A link pressed with a modifier opens elsewhere and leaves the page as it is.
  • The list keeps its list role when styled without bullets.
  • Steps at the ends stay in the tab order and say they're disabled.
  • A Next link goes to the next page, not past it, even after the page renders again.
  • A Next link onto the last page still goes there, though it ends there.

Runs in React, Vue and web components.

Screen readers hear the same thing in each tested framework, recorded by a virtual screen reader:

  • Announcements while paging with a keyboard.

Runs in React, Vue and web components.