Vue componentFree

Pagination Bar

Vue 3 pagination bar with v-model:page, smart ellipsis, prev/next, a page-size picker, compact mobile view and a “Showing X–Y of Z” summary. Tailwind CSS.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

The footer every data table needs. Give it the total item count and a page size, bind v-model:page, and it works out the page numbers, the “Showing 21–30 of 248 results” line and which buttons to disable.

Ellipsis logic keeps the number of buttons constant as you move through the pages, so nothing jumps under the cursor. Tune it with siblings (pages either side of the current one) and boundaries (pages pinned to each end). On phones it collapses to Previous, “Page 3 of 25” and Next.

Pass pageSizeOptions to add a per-page select bound with v-model:page-size; when the size changes, the page is adjusted so the first visible item stays on screen. The current page is marked with aria-current and the controls sit in a labelled nav landmark.

  • v-model:page and v-model:page-size
  • Stable ellipsis with configurable siblings and boundaries
  • “Showing X–Y of Z” summary with a custom item noun
  • Compact “Page X of Y” view on mobile
  • aria-current and labelled nav landmark

Code

11 files in the repo
<script setup lang="ts">
import { computed } from 'vue'

export type PageItem = number | 'start-ellipsis' | 'end-ellipsis'

const props = withDefaults(defineProps<{
  /** Total number of items across all pages. */
  total?: number
  /** Page numbers shown on each side of the current page. */
  siblings?: number
  /** Page numbers always shown at the start and end. */
  boundaries?: number
  /** Show the "Showing X–Y of Z" summary. */
  showSummary?: boolean
  /** Offer a page-size picker with these options. Empty hides it. */
  pageSizeOptions?: number[]
  /** Plural noun used in the summary, e.g. "results" or "invoices". */
  itemLabel?: string
  /** Accessible label of the nav landmark. */
  label?: string
}>(), {
  total: 248,
  siblings: 1,
  boundaries: 1,
  showSummary: true,
  pageSizeOptions: () => [],
  itemLabel: 'results',
  label: 'Pagination',
})

const emit = defineEmits<{
  change: [page: number, pageSize: number]
}>()

/** Current page, 1-based. Works with `v-model:page` or on its own. */
const page = defineModel<number>('page', { default: 1 })
/** Items per page. Works with `v-model:page-size`. */
const pageSize = defineModel<number>('pageSize', { default: 10 })

const pageCount = computed(() => Math.max(1, Math.ceil(props.total / Math.max(1, pageSize.value))))
const current = computed(() => Math.min(Math.max(1, Math.round(page.value)), pageCount.value))

const range = (from: number, to: number) => (to < from ? [] : Array.from({ length: to - from + 1 }, (_, i) => from + i))

/**
 * Keeps the number of slots constant as you page through, so buttons don't jump around:
 * boundaries + ellipsis + siblings + current + siblings + ellipsis + boundaries.
 */
const items = computed<PageItem[]>(() => {
  const count = pageCount.value
  const currentPage = current.value
  const { siblings, boundaries } = props
  const slots = boundaries * 2 + siblings * 2 + 3
  if (count <= slots) return range(1, count)

  const start = range(1, boundaries)
  const end = range(count - boundaries + 1, count)
  const siblingsStart = Math.max(Math.min(currentPage - siblings, count - boundaries - siblings * 2 - 1), boundaries + 2)
  const siblingsEnd = Math.min(Math.max(currentPage + siblings, boundaries + siblings * 2 + 2), count - boundaries - 1)

  return [
    ...start,
    ...(siblingsStart > boundaries + 2 ? ['start-ellipsis' as const] : [boundaries + 1]),
    ...range(siblingsStart, siblingsEnd),
    ...(siblingsEnd < count - boundaries - 1 ? ['end-ellipsis' as const] : [count - boundaries]),
    ...end,
  ]
})

const first = computed(() => (props.total === 0 ? 0 : (current.value - 1) * pageSize.value + 1))
const last = computed(() => Math.min(current.value * pageSize.value, props.total))
const fmt = (n: number) => n.toLocaleString()

function go(target: number) {
  const next = Math.min(Math.max(1, target), pageCount.value)
  if (next === current.value) return
  page.value = next
  emit('change', next, pageSize.value)
}

function changeSize(event: Event) {
  const size = Number((event.target as HTMLSelectElement).value)
  // Keep the first visible item on screen after the page size changes.
  const next = Math.floor(Math.max(0, first.value - 1) / size) + 1
  pageSize.value = size
  page.value = next
  emit('change', next, size)
}

const base = 'inline-flex h-9 items-center justify-center rounded-lg text-sm font-medium transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600 dark:focus-visible:outline-rose-400'
const idle = 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10'
const disabledCls = 'disabled:pointer-events-none disabled:opacity-40'
</script>

<template>
  <nav :aria-label="label" class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
    <div v-if="showSummary || pageSizeOptions.length" class="flex items-center justify-between gap-4 text-sm text-gray-600 lg:justify-start dark:text-gray-400">
      <p v-if="showSummary" aria-live="polite">
        <template v-if="total > 0">
          Showing <span class="font-medium text-gray-900 tabular-nums dark:text-white">{{ fmt(first) }}–{{ fmt(last) }}</span>
          of <span class="font-medium text-gray-900 tabular-nums dark:text-white">{{ fmt(total) }}</span> {{ itemLabel }}
        </template>
        <template v-else>No {{ itemLabel }}</template>
      </p>
      <label v-if="pageSizeOptions.length" class="flex items-center gap-2">
        <span class="hidden sm:inline">Per page</span>
        <span class="sr-only sm:hidden">Items per page</span>
        <select
          :value="pageSize"
          class="h-9 rounded-lg border-0 bg-white py-0 pr-1.5 pl-2.5 text-sm text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-rose-600 focus:outline-none dark:bg-white/5 dark:text-white dark:ring-white/10 dark:focus:ring-rose-400 [&>option]:bg-white dark:[&>option]:bg-gray-900"
          @change="changeSize"
        >
          <option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}</option>
        </select>
      </label>
    </div>

    <div class="flex items-center justify-between gap-1 lg:justify-end">
      <button
        type="button"
        :class="[base, idle, disabledCls, 'gap-1 px-2.5']"
        :disabled="current <= 1"
        aria-label="Previous page"
        @click="go(current - 1)"
      >
        <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5">
          <path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
        </svg>
        <span class="hidden sm:inline">Previous</span>
      </button>

      <!-- Compact view for small screens -->
      <p class="px-3 text-sm text-gray-600 sm:hidden dark:text-gray-400">
        Page <span class="font-semibold text-gray-900 dark:text-white">{{ current }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ pageCount }}</span>
      </p>

      <ul class="hidden items-center gap-1 sm:flex">
        <li v-for="item in items" :key="item">
          <span v-if="typeof item === 'string'" class="inline-flex h-9 w-9 items-center justify-center text-sm text-gray-400 dark:text-gray-500" aria-hidden="true">…</span>
          <button
            v-else
            type="button"
            :class="[base, 'min-w-9 px-2 tabular-nums', item === current
              ? 'bg-rose-600 text-white shadow-sm hover:bg-rose-600 dark:bg-rose-500'
              : idle]"
            :aria-current="item === current ? 'page' : undefined"
            :aria-label="`Page ${item}`"
            @click="go(item)"
          >
            {{ item }}
          </button>
        </li>
      </ul>

      <button
        type="button"
        :class="[base, idle, disabledCls, 'gap-1 px-2.5']"
        :disabled="current >= pageCount"
        aria-label="Next page"
        @click="go(current + 1)"
      >
        <span class="hidden sm:inline">Next</span>
        <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5">
          <path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
        </svg>
      </button>
    </div>
  </nav>
</template>

Props

PropTypeDefaultDescription
pagenumber1Current page, 1-based. Bind with v-model:page; works uncontrolled too.
totalnumber248Total number of items across all pages.
pageSizenumber10Items per page. Bind with v-model:page-size when using pageSizeOptions.
siblingsnumber1Page numbers shown on each side of the current page.
boundariesnumber1Page numbers always shown at the start and end.
showSummarybooleantrueShow the “Showing X–Y of Z” summary.
pageSizeOptionsnumber[][]Options for a per-page select. Leave empty to hide it.
itemLabelstring'results'Plural noun used in the summary, e.g. 'invoices'.
labelstring'Pagination'Accessible label of the nav landmark.

Events

EventPayloadDescription
update:page(page: number)Emitted when the user moves to another page.
update:pageSize(pageSize: number)Emitted when a new page size is picked.
change(page: number, pageSize: number)Emitted after any page or page-size change, handy for fetching data.
All navigation →
Preview of Command Palette$9

Component · Navigation

Command Palette

A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.

by Vueplay
Preview of Dropdown MenuFree

Component · Navigation

Dropdown Menu

Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.

by Vueplay
Preview of LinkFree

Component · Navigation

Link

A flexible link component that can act as a router-link for internal navigation or an href for external URLs. Can be used on its own or inside a Navigator.

Preview of Sidebar NavFree

Component · Navigation

Sidebar Nav

Vue 3 app sidebar with sections, icons, badges, collapsible groups, an icon-only collapsed mode and a mobile off-canvas drawer. Tailwind CSS.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings