← Blog
16 Aug 2026vue js paginationvue paginationvue componentsui paginationvue accessibility

Vue JS Pagination: A Practical Implementation Guide

Master vue js pagination with this hands-on guide covering client-side, server-side, infinite scroll, accessibility, and performance.

Vue JS Pagination: A Practical Implementation Guide

You’ve got a Vue list that worked perfectly in development. Then the data grows, the first render becomes sluggish, users lose their place when they hit Back, and a screen-reader user reports that changing pages produces no useful announcement. Search results look acceptable until someone asks for a link to a specific page.

That’s the point where Vue JS pagination stops being a computed slice() and becomes a product decision. The control has to describe where users are, preserve navigation history, cope with changing data, and avoid making the browser hold more records than it needs. The implementation pattern depends on the dataset, where the source of truth lives, whether pages need stable URLs, and how assistive technology will move through the results.

Table of Contents

When a List Outgrows a Loop

Pagination usually arrives after the list itself has exposed the problem. A table starts with a modest collection, so rendering every row feels harmless. Later, filtering becomes slower, the initial paint takes longer on weaker devices, and a user returning from a detail screen lands at the top instead of the page they were viewing. A search result that can’t be opened directly at a particular page becomes a support issue rather than a styling issue.

A production pagination decision starts with four questions:

  • How large can the dataset become? If the browser already has every record, client-side slicing is simple. If the collection can become large or expensive to serialise, let the server select the page.
  • Who owns the source of truth? A local array is convenient for static or already-loaded data. A database-backed query should normally own filtering, sorting, totals, and page boundaries.
  • Does the page need a URL? Search results, admin tables, and reports usually benefit from bookmarkable page state. A local ref can’t restore a page after refresh without additional URL synchronisation.
  • How will assistive technology understand movement? A visual row change isn’t enough. Users need a navigation landmark, an identifiable current page, and links that explain their destination.

For a deeper treatment of the surrounding data-grid decisions, the data grids guide is a useful companion. The important distinction is that pagination is a UX contract, not a loop around an array.

Choosing the pattern

Client-side pagination fits a bounded collection already present in memory. It provides immediate page changes and little network code, but it still pays the cost of loading and retaining the whole list.

Server-side pagination fits large, changing, searchable, or permission-sensitive collections. The API returns the current page and enough metadata for the control, while the URL can represent the same state.

Infinite scroll fits continuous, homogeneous browsing where users don’t need to identify or revisit a precise page. It isn’t a replacement for numbered navigation when deep links, comparison, or task completion matter.

A team often gets into trouble by choosing the visually smallest control first. Decide what users need to revisit, share, announce, and recover before choosing the rendering technique.

Client-Side Pagination with Reactive State

For an in-memory list, keep the current page reactive and derive the visible rows. The slice shouldn’t be copied into manually managed state, because that creates another value that can drift away from the source array when filtering or sorting changes.

The smallest useful implementation

This example keeps the pagination logic in a component or composable. The same state can live in a Pinia store if several views need to share the collection.

<script setup>
import { computed, nextTick, ref, watch } from 'vue'

const rows = ref([])
const currentPage = ref(1)
const pageSize = 20
const listRegion = ref(null)

const totalPages = computed(() =>
  Math.ceil(rows.value.length / pageSize)
)

const visibleRows = computed(() => {
  const page = Math.min(
    Math.max(currentPage.value, 1),
    Math.max(totalPages.value, 1)
  )

  const start = (page - 1) * pageSize
  return rows.value.slice(start, start + pageSize)
})

const hasPrevious = computed(() => currentPage.value > 1)
const hasNext = computed(() => currentPage.value < totalPages.value)

function goToPage(page) {
  currentPage.value = Math.min(
    Math.max(page, 1),
    Math.max(totalPages.value, 1)
  )
}

watch(totalPages, pages => {
  if (pages === 0) {
    currentPage.value = 1
    return
  }

  if (currentPage.value > pages) {
    currentPage.value = pages
  }
})

watch(currentPage, async () => {
  await nextTick()
  listRegion.value?.focus()
})
</script>

<template>
  <section
    ref="listRegion"
    tabindex="-1"
    aria-labelledby="results-heading"
  >
    <h2 id="results-heading">Results</h2>

    <p v-if="totalPages === 0">No results found.</p>

    <ul v-else :key="currentPage">
      <li v-for="row in visibleRows" :key="row.id">
        {{ row.name }}
      </li>
    </ul>
  </section>
</template>

The computed values update when the source array changes. The clamp matters when a filter removes the last rows from the current page. Without it, the interface can show an empty slice even though earlier pages still contain results.

The empty-state branch is separate from the list, because an empty collection isn’t the same thing as a page with no markup. The watch on currentPage returns focus to the list region, not the document body. That gives keyboard and screen-reader users a reliable place to continue after a page change. The :key on the list also makes the page transition explicit to Vue, which can prevent confusing row reuse when the visible slice changes.

Practical rule: Reset the page when filters change, clamp it when the collection shrinks, and move focus deliberately after navigation.

Client-side pagination breaks down when the array becomes large, when slower devices pay for records they won’t display, or when a page must be reachable through a URL. It also doesn’t solve server-side filtering or sorting. At that point, the browser should request the page it needs instead of pretending the entire dataset is local.

Wiring a Paginated API

A server-backed endpoint should receive the page and page size, then return the records for that page alongside a server-calculated total. The server remains responsible for filtering and sorting, so the client doesn’t build a second, possibly inconsistent version of the query.

A typical response shape might contain items, currentPage, pageSize, and totalItems. The exact names aren’t important. What matters is that the UI can derive the available pages without guessing from the length of the current response.

Put request state in a composable

The component should render loading, error, and results. It shouldn’t also contain abort handling and stale-response protection.

import { ref, watch } from 'vue'

export function usePaginatedRows(page, pageSize) {
  const items = ref([])
  const totalItems = ref(0)
  const loading = ref(false)
  const error = ref(null)

  let controller

  async function load() {
    controller?.abort()
    controller = new AbortController()

    loading.value = true
    error.value = null

    try {
      const query = new URLSearchParams({
        page: String(page.value),
        pageSize: String(pageSize)
      })

      const response = await fetch(`/api/rows?${query}`, {
        signal: controller.signal
      })

      if (!response.ok) {
        throw new Error('Unable to load results')
      }

      const data = await response.json()
      items.value = data.items
      totalItems.value = data.totalItems
    } catch (exception) {
      if (exception.name !== 'AbortError') {
        error.value = exception
      }
    } finally {
      if (!controller.signal.aborted) {
        loading.value = false
      }
    }
  }

  watch(page, load, { immediate: true })

  return { items, totalItems, loading, error, reload: load }
}

AbortController prevents rapid clicks from allowing an older response to overwrite a newer page. Framework-specific helpers such as useAsyncData, or a carefully designed watchEffect wrapper, can provide the same reactive relationship. The key is that the request must follow the page and query state, and cancellation must be part of the design rather than an afterthought.

Make page state navigable

If page state matters to the user, put it in the route query rather than only in a component ref. With the History API, use pushState only when the page changes. Repeating the same page in history creates noisy Back-button behaviour.

function updatePage(page) {
  if (page === currentPage.value) return

  currentPage.value = page

  const url = new URL(window.location.href)
  url.searchParams.set('page', String(page))
  window.history.pushState({ page }, '', url)
}

On startup, parse the query parameter, validate it, and request that page. Listen for popstate so browser history updates the reactive page as well. If the requested page no longer exists because records were removed or filters changed, redirect to the last valid page. The GOV.UK Design System pagination guidance explicitly recommends sending users to page 1 when a requested page no longer exists, and its broader rules also emphasise page context in titles and omitting pagination when only one page exists. In other products, redirecting to the last valid page may preserve more useful context, but the guard must be intentional and tested.

Infinite Scroll and Load More Patterns

Infinite scroll changes the navigation model. Users no longer choose a page, so the interface must communicate loading, completion, and failure without relying on numbered links. Treating it as “pagination with a different button” leaves important gaps around keyboard access, recovery, and returning to a precise position.

IntersectionObserver is a better trigger than a scroll listener for most lists. Place a sentinel after the current items and observe whether it enters the viewport.

import { onBeforeUnmount, onMounted } from 'vue'

export function useInfiniteScroll(sentinel, loadMore) {
  let observer

  onMounted(() => {
    observer = new IntersectionObserver(entries => {
      if (entries[0]?.isIntersecting) {
        loadMore()
      }
    })

    if (sentinel.value) {
      observer.observe(sentinel.value)
    }
  })

  onBeforeUnmount(() => {
    observer?.disconnect()
  })
}

The callback still needs guards. Ignore a trigger while a request is active, deduplicate records by a stable identifier, and stop observing when the server says there are no more items. An unstable page key can produce duplicate rows when the user reaches the sentinel repeatedly or when a request resolves out of order.

Load More is often the safer default

A Load More button is easier to discover, test, focus, and retry. It also gives users control over whether to request more content, which matters when the list contains mixed content or when each additional request has a meaningful cost.

IntersectionObserver-driven loading makes sense for homogeneous browsing where users naturally continue past the initial results and don’t need to stop at a particular boundary. Load More is the stronger default when users compare items, need a clear stopping point, or may have a slow or unreliable connection.

Whichever pattern you choose, expose the loading state, preserve existing content while the next batch arrives, show a recoverable error, and remove or disable the trigger when the server has no more records. Do not leave a blank gap at the end of the list.

Accessible Pagination That Screen Readers Actually Use

A pagination control can look polished and still leave assistive-technology users unsure what changed. The component needs a semantic structure that communicates location, destination, and current state without depending on visual styling.

The UK accessibility guidance for pagination is direct about the essentials:

  • Use a landmark. Wrap the control in <nav> and provide a descriptive aria-label, such as “Search results pagination”.
  • Expose the current page. Apply aria-current="page" to the active link, not just a selected colour or bold class.
  • Describe destinations. A link labelled only “3” is ambiguous out of context. Use accessible text such as “Go to page 3 of results”, while keeping the visual label concise.
  • Keep focus predictable. Previous and next controls must remain keyboard reachable, and focus shouldn’t become trapped at either end.

Previous and next alone can be especially weak. A user may hear that a control goes forward without hearing which page they’re on or how far away another result is. The guidance notes that patterns exposing only previous and next can leave users unable to identify their current page. A numbered pattern is usually more appropriate when users need orientation, direct access, or a way to revisit a known result range.

Announce the content change

Changing the list visually doesn’t automatically announce the new context. After a successful request, update a polite live region with useful information, such as the current page and the number of visible results. Don’t move focus into an arbitrary row, because that can make the user lose the surrounding structure. Move focus to a stable heading or list-region anchor, then let the live region provide the concise status.

The pagination controls guidance from DOM Studio is relevant when deciding how much behaviour belongs in a reusable control. Keep labels, disabled states, current-page semantics, and keyboard behaviour consistent. Don’t hide the only indication of position in CSS, and don’t use a clickable <span> where a normal link would preserve expected browser behaviour.

Building Accessible Pagination with DOM Studio Primitives

Pagination becomes repetitive once the list needs landmark semantics, current-page state, focus handling, keyboard interaction, disabled controls, and responsive variants. Reusable primitives from DOM Studio can replace many hand-rolled ARIA patterns, while the application retains control of its visual language and data-fetching logic.

A practical integration should expose Vue-facing v-model:current-page, accept the total page count, and provide slots for page links, ellipses, and previous and next icons. The headless layer can manage roles, current-page semantics, and focus behaviour. The wrapper then connects those states to the application’s data-fetching composable.

<DomPagination
  v-model:current-page="currentPage"
  :total-pages="totalPages"
  aria-label="Search results pagination"
  class="flex items-center gap-2"
>
  <template #ellipsis>
    <span aria-hidden="true">…</span>
  </template>
</DomPagination>

Tailwind CSS 4 utility classes can style the surrounding layout without tying pagination logic to a design system. Slots also accommodate icon buttons, translated labels, and compact mobile treatments without duplicating the interaction model.

Screenshot from https://getdom.studio

Inspect the abstraction before shipping it

DOM Studio’s modules are tree-shakeable, and individual components average under 2 kb gzipped, according to the product brief. Selective adoption is possible, but the application’s build configuration and imported modules still determine the final bundle impact.

The useful engineering question is whether the abstraction remains inspectable and extendable. Visual Blocks can expose a theming surface, while AI-editable component specifications, embedded documentation, and inspector hints give the team material to review after generation. The same principle applies to composition strategies for reusable components: keep behaviour reusable, then compose presentation around the product context.

Testing and Choosing the Right Pattern

Pagination needs tests at three levels. Unit-test the slice or API-state logic for an empty collection, a page beyond the available range, and a collection whose size changes after filtering. Component-test that aria-current follows the active page and that destination labels identify the page context. End-to-end test a deep link to an unavailable page and verify that the application redirects cleanly rather than rendering an empty, confusing result.

Choose the pattern against the actual constraints:

Requirement Suitable default
Bounded data already in memory Client-side pagination
Large, changing, searchable, or permission-sensitive data Server-side pagination
Continuous browsing with no need for precise page URLs Infinite scroll or Load More
Bookmarkable results or strong SEO requirements Server-side pagination with stable URLs

The GOV.UK Vue pagination component documentation also captures useful implementation details: it supports block-style and numbered-list modes, defaults to block-style when total-pages isn’t supplied, starts page numbers at 1, supports v-model:current-page, and provides page-href placeholders for normal links. Those details reinforce the same PR rule I use: choose pagination from the navigation contract first, then implement the smallest pattern that preserves URL state, performance, and accessible context.


DOM Studio offers headless primitives and Vue wrappers for pagination behaviour, reactive bindings, focus management, and accessible state, with Tailwind CSS 4 utilities and composable theming through Visual Blocks. Visit DOM Studio to inspect the component model and decide whether it fits the pagination work in your Vue application.