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.
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
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.
<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>
| Prop | Type | Default | Description |
|---|---|---|---|
| page | number | 1 | Current page, 1-based. Bind with v-model:page; works uncontrolled too. |
| total | number | 248 | Total number of items across all pages. |
| pageSize | number | 10 | Items per page. Bind with v-model:page-size when using pageSizeOptions. |
| siblings | number | 1 | Page numbers shown on each side of the current page. |
| boundaries | number | 1 | Page numbers always shown at the start and end. |
| showSummary | boolean | true | Show the “Showing X–Y of Z” summary. |
| pageSizeOptions | number[] | [] | Options for a per-page select. Leave empty to hide it. |
| itemLabel | string | 'results' | Plural noun used in the summary, e.g. 'invoices'. |
| label | string | 'Pagination' | Accessible label of the nav landmark. |
| Event | Payload | Description |
|---|---|---|
| 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. |
Component · Navigation
A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.
Component · Navigation
Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.
Component · Navigation
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.
Component · Navigation
Vue 3 app sidebar with sections, icons, badges, collapsible groups, an icon-only collapsed mode and a mobile off-canvas drawer. Tailwind CSS.