Component · Navigation
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.
Generic, typed Vue 3 data table with click-to-sort columns, search, checkbox row selection and pagination. Tailwind CSS, dark mode, mobile scroll.
by Vueplayv1.0.0Updated 2 hours ago
A data table for admin screens, billing pages and member lists: sortable headers, a search box that filters every column, checkbox selection with a tri-state select-all, pagination and a friendly empty state. On narrow screens the table scrolls horizontally inside its card instead of breaking the page.
It is written with <script setup generic="T">, so your row type flows into the column config and into every slot. Columns take a key, label, sortable, align and an optional format function; sorting always uses the raw value, so formatted dates and currency still sort correctly.
Override any cell with a cell-<key> slot for badges, avatars or actions, add bulk buttons through the toolbar slot, and bind v-model:selected to get the selected row keys. Set pageSize to 0 to show every row.
<script lang="ts">
export interface DataTableColumn<Row = Record<string, unknown>> {
/** Property of the row to read. */
key: keyof Row & string
label: string
sortable?: boolean
align?: 'left' | 'center' | 'right'
/** Turn a raw value into display text. Sorting still uses the raw value. */
format?: (value: Row[keyof Row], row: Row) => string
/** Extra classes for the body cells in this column. */
class?: string
}
export type SortDirection = 'asc' | 'desc'
export interface SortState { key: string; direction: SortDirection }
// Sample data lives in a plain script block so defineProps defaults can reference it.
const DEMO_ROWS = [
{ id: 'INV-1042', customer: 'Harbor & Pine', email: 'billing@harborpine.test', status: 'Paid', amount: 1840, date: '2026-09-24' },
{ id: 'INV-1041', customer: 'Quillstone', email: 'ap@quillstone.test', status: 'Pending', amount: 620, date: '2026-09-22' },
{ id: 'INV-1040', customer: 'Northstar Labs', email: 'finance@northstar.test', status: 'Paid', amount: 4200, date: '2026-09-19' },
{ id: 'INV-1039', customer: 'Fieldnote', email: 'hello@fieldnote.test', status: 'Overdue', amount: 310, date: '2026-09-12' },
{ id: 'INV-1038', customer: 'Lumen Studio', email: 'ops@lumen.test', status: 'Paid', amount: 2750, date: '2026-09-10' },
{ id: 'INV-1037', customer: 'Brightwater Co.', email: 'accounts@brightwater.test', status: 'Refunded', amount: 95, date: '2026-09-08' },
{ id: 'INV-1036', customer: 'Tallgrass Farm', email: 'books@tallgrass.test', status: 'Pending', amount: 1120, date: '2026-09-05' },
{ id: 'INV-1035', customer: 'Orbitly', email: 'pay@orbitly.test', status: 'Paid', amount: 580, date: '2026-09-02' },
{ id: 'INV-1034', customer: 'Cobalt Bakery', email: 'owner@cobalt.test', status: 'Overdue', amount: 240, date: '2026-08-28' },
{ id: 'INV-1033', customer: 'Meridian Health', email: 'ap@meridian.test', status: 'Paid', amount: 6980, date: '2026-08-25' },
{ id: 'INV-1032', customer: 'Wren & Co.', email: 'studio@wren.test', status: 'Paid', amount: 1460, date: '2026-08-21' },
{ id: 'INV-1031', customer: 'Solstice Travel', email: 'finance@solstice.test', status: 'Pending', amount: 890, date: '2026-08-18' },
]
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
</script>
<script setup lang="ts" generic="T extends Record<string, unknown>">
import { computed, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
rows?: T[]
columns?: DataTableColumn<T>[]
/** Property that uniquely identifies a row. Used for keys and selection. */
rowKey?: keyof T & string
caption?: string
/** Rows per page. Use 0 to disable pagination. */
pageSize?: number
selectable?: boolean
filterable?: boolean
filterPlaceholder?: string
emptyText?: string
/** Keys of selected rows (v-model:selected). */
selected?: Array<T[keyof T]>
defaultSort?: SortState
}>(), {
rows: () => DEMO_ROWS as unknown as T[],
columns: () => [
{ key: 'id', label: 'Invoice', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
{ key: 'customer', label: 'Customer', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
{ key: 'date', label: 'Issued', sortable: true, format: (v: unknown) => new Date(`${String(v)}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) },
{ key: 'amount', label: 'Amount', sortable: true, align: 'right', format: (v: unknown) => usd.format(Number(v)), class: 'tabular-nums' },
] as unknown as DataTableColumn<T>[],
rowKey: 'id' as never,
caption: 'Invoices',
pageSize: 5,
selectable: true,
filterable: true,
filterPlaceholder: 'Search…',
emptyText: 'No rows match your search.',
selected: undefined,
defaultSort: undefined,
})
const emit = defineEmits<{
'update:selected': [keys: Array<T[keyof T]>]
sort: [sort: SortState | null]
'row-click': [row: T]
}>()
defineSlots<{
/** Custom cell rendering. Slot name is `cell-<column key>`. */
[name: `cell-${string}`]: (props: { row: T; value: T[keyof T]; column: DataTableColumn<T> }) => unknown
/** Buttons shown next to the search field, e.g. bulk actions. */
toolbar?: (props: { selectedCount: number }) => unknown
empty?: () => unknown
}>()
const query = ref('')
const sort = ref<SortState | null>(props.defaultSort ?? null)
const page = ref(1)
// Selection works controlled (v-model:selected) or uncontrolled.
const localSelected = ref<Array<T[keyof T]>>([])
const selectedKeys = computed(() => props.selected ?? localSelected.value)
function setSelected(keys: Array<T[keyof T]>) {
localSelected.value = keys
emit('update:selected', keys)
}
const keyOf = (row: T) => row[props.rowKey]
function display(column: DataTableColumn<T>, row: T): string {
const value = row[column.key]
if (column.format) return column.format(value, row)
return value == null ? '' : String(value)
}
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.rows
return props.rows.filter(row => props.columns.some(col => display(col, row).toLowerCase().includes(q)))
})
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sorted = computed(() => {
const s = sort.value
if (!s) return filtered.value
const factor = s.direction === 'asc' ? 1 : -1
return [...filtered.value].sort((a, b) => {
const x = a[s.key], y = b[s.key]
if (x == null) return 1
if (y == null) return -1
if (typeof x === 'number' && typeof y === 'number') return (x - y) * factor
return collator.compare(String(x), String(y)) * factor
})
})
const pageCount = computed(() => props.pageSize > 0 ? Math.max(1, Math.ceil(sorted.value.length / props.pageSize)) : 1)
const visible = computed(() => {
if (props.pageSize <= 0) return sorted.value
const start = (page.value - 1) * props.pageSize
return sorted.value.slice(start, start + props.pageSize)
})
const rangeLabel = computed(() => {
const total = sorted.value.length
if (!total) return '0 results'
const start = props.pageSize > 0 ? (page.value - 1) * props.pageSize + 1 : 1
const end = start + visible.value.length - 1
return `${start}–${end} of ${total}`
})
watch(query, () => { page.value = 1 })
watch(pageCount, (n) => { if (page.value > n) page.value = n })
function toggleSort(column: DataTableColumn<T>) {
if (!column.sortable) return
const cur = sort.value
if (!cur || cur.key !== column.key) sort.value = { key: column.key, direction: 'asc' }
else if (cur.direction === 'asc') sort.value = { key: column.key, direction: 'desc' }
else sort.value = null
emit('sort', sort.value)
}
function ariaSort(column: DataTableColumn<T>) {
if (!column.sortable) return undefined
if (sort.value?.key !== column.key) return 'none'
return sort.value.direction === 'asc' ? 'ascending' : 'descending'
}
const isSelected = (row: T) => selectedKeys.value.includes(keyOf(row))
function toggleRow(row: T) {
const k = keyOf(row)
setSelected(isSelected(row) ? selectedKeys.value.filter(x => x !== k) : [...selectedKeys.value, k])
}
const visibleKeys = computed(() => visible.value.map(keyOf))
const allVisibleSelected = computed(() => visibleKeys.value.length > 0 && visibleKeys.value.every(k => selectedKeys.value.includes(k)))
const someVisibleSelected = computed(() => !allVisibleSelected.value && visibleKeys.value.some(k => selectedKeys.value.includes(k)))
function toggleAll() {
if (allVisibleSelected.value) setSelected(selectedKeys.value.filter(k => !visibleKeys.value.includes(k)))
else setSelected([...new Set([...selectedKeys.value, ...visibleKeys.value])])
}
const alignClass = (a?: 'left' | 'center' | 'right') => a === 'right' ? 'text-right' : a === 'center' ? 'text-center' : 'text-left'
const justifyClass = (a?: 'left' | 'center' | 'right') => a === 'right' ? 'justify-end' : a === 'center' ? 'justify-center' : 'justify-start'
const searchId = `dt-search-${Math.random().toString(36).slice(2, 8)}`
</script>
<template>
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:scheme-dark">
<div v-if="filterable || caption || $slots.toolbar" class="flex flex-col gap-3 border-b border-gray-200 p-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-800">
<div class="flex items-baseline gap-3">
<h3 v-if="caption" class="text-base font-semibold text-gray-900 dark:text-white">{{ caption }}</h3>
<span v-if="selectable && selectedKeys.length" class="text-sm text-sky-700 dark:text-sky-300" aria-live="polite">{{ selectedKeys.length }} selected</span>
</div>
<div class="flex items-center gap-2">
<slot name="toolbar" :selected-count="selectedKeys.length" />
<div v-if="filterable" class="relative w-full sm:w-64">
<label :for="searchId" class="sr-only">Filter rows</label>
<svg viewBox="0 0 20 20" aria-hidden="true" class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 fill-gray-400">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.45 4.39l3.33 3.33a.75.75 0 1 1-1.06 1.06l-3.33-3.33A7 7 0 0 1 2 9Z" clip-rule="evenodd" />
</svg>
<input
:id="searchId"
v-model="query"
type="search"
:placeholder="filterPlaceholder"
class="w-full rounded-lg border border-gray-300 bg-white py-2 pr-3 pl-9 text-sm text-gray-900 placeholder:text-gray-400 focus:border-sky-500 focus:ring-2 focus:ring-sky-500/30 focus:outline-none dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-500"
>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[40rem] text-sm">
<caption class="sr-only">{{ caption || 'Data table' }}</caption>
<thead class="bg-gray-50 text-xs font-medium tracking-wide text-gray-500 uppercase dark:bg-gray-950/50 dark:text-gray-400">
<tr>
<th v-if="selectable" scope="col" class="w-12 px-4 py-3">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="someVisibleSelected"
:disabled="!visible.length"
aria-label="Select all rows on this page"
class="size-4 rounded border-gray-300 accent-sky-600 dark:border-gray-600"
@change="toggleAll"
>
</th>
<th
v-for="column in columns"
:key="column.key"
scope="col"
:aria-sort="ariaSort(column)"
class="px-4 py-3 whitespace-nowrap"
:class="alignClass(column.align)"
>
<button
v-if="column.sortable"
type="button"
class="group inline-flex items-center gap-1 rounded uppercase hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 dark:hover:text-white"
:class="[justifyClass(column.align), sort?.key === column.key && 'text-gray-900 dark:text-white']"
@click="toggleSort(column)"
>
{{ column.label }}
<svg viewBox="0 0 20 20" aria-hidden="true" class="size-4 transition" :class="sort?.key === column.key ? 'fill-sky-600 dark:fill-sky-400' : 'fill-gray-300 group-hover:fill-gray-400 dark:fill-gray-600'">
<path v-if="sort?.key !== column.key" d="M10 3.5 6.5 7.5h7L10 3.5Zm0 13 3.5-4h-7l3.5 4Z" />
<path v-else-if="sort.direction === 'asc'" d="M10 5.5 5.5 11h9L10 5.5Z" />
<path v-else d="M10 14.5 14.5 9h-9l4.5 5.5Z" />
</svg>
</button>
<span v-else>{{ column.label }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<tr
v-for="row in visible"
:key="String(keyOf(row))"
class="transition-colors"
:class="selectable && isSelected(row) ? 'bg-sky-50/70 dark:bg-sky-500/10' : 'hover:bg-gray-50 dark:hover:bg-gray-800/40'"
@click="emit('row-click', row)"
>
<td v-if="selectable" class="px-4 py-3" @click.stop>
<input
type="checkbox"
:checked="isSelected(row)"
:aria-label="`Select row ${String(keyOf(row))}`"
class="size-4 rounded border-gray-300 accent-sky-600 dark:border-gray-600"
@change="toggleRow(row)"
>
</td>
<td
v-for="column in columns"
:key="column.key"
class="px-4 py-3 whitespace-nowrap text-gray-600 dark:text-gray-300"
:class="[alignClass(column.align), column.class]"
>
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :column="column">{{ display(column, row) }}</slot>
</td>
</tr>
<tr v-if="!visible.length">
<td :colspan="columns.length + (selectable ? 1 : 0)" class="px-4 py-14 text-center">
<slot name="empty">
<svg viewBox="0 0 24 24" aria-hidden="true" class="mx-auto size-8 fill-none stroke-gray-300 dark:stroke-gray-600" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z" />
</svg>
<p class="mt-2 font-medium text-gray-900 dark:text-white">{{ emptyText }}</p>
<button v-if="query" type="button" class="mt-2 text-sm font-medium text-sky-700 hover:underline dark:text-sky-400" @click="query = ''">Clear search</button>
</slot>
</td>
</tr>
</tbody>
</table>
</div>
<nav v-if="pageSize > 0" aria-label="Pagination" class="flex items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm dark:border-gray-800">
<p class="text-gray-500 dark:text-gray-400">{{ rangeLabel }}</p>
<div class="flex items-center gap-1">
<button
type="button"
class="rounded-lg border border-gray-300 px-3 py-1.5 font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
:disabled="page <= 1"
@click="page--"
>
Previous
</button>
<span class="px-2 text-gray-500 tabular-nums dark:text-gray-400" aria-live="polite">{{ page }} / {{ pageCount }}</span>
<button
type="button"
class="rounded-lg border border-gray-300 px-3 py-1.5 font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
:disabled="page >= pageCount"
@click="page++"
>
Next
</button>
</div>
</nav>
</div>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| rows | T[] | 12 sample invoices | Rows to display. The row type is inferred and used for columns and slots. |
| columns | DataTableColumn<T>[] | Invoice, Customer, Status, Issued, Amount | Column config: key, label, sortable, align ('left' | 'center' | 'right'), format(value, row) and class for body cells. |
| rowKey | keyof T | 'id' | Property that uniquely identifies each row; used for keys and selection. |
| caption | string | 'Invoices' | Title shown in the toolbar and used as the table caption for screen readers. |
| pageSize | number | 5 | Rows per page. 0 disables pagination. |
| selectable | boolean | true | Show a checkbox column with select-all. |
| filterable | boolean | true | Show the search field that filters across all columns. |
| filterPlaceholder | string | 'Search…' | Placeholder text for the search field. |
| emptyText | string | 'No rows match your search.' | Message shown when there are no rows to display. |
| selected | Array<T[keyof T]> | undefined | Selected row keys. Use with v-model:selected, or leave unbound for internal state. |
| defaultSort | { key: string; direction: 'asc' | 'desc' } | undefined | Initial sort column and direction. |
| Event | Payload | Description |
|---|---|---|
| update:selected | (keys: Array<T[keyof T]>) | Selection changed. |
| sort | (sort: SortState | null) | Sort column or direction changed; null when sorting is cleared. |
| row-click | (row: T) | A row was clicked (checkbox clicks excluded). |
| Slot | Description |
|---|---|
| cell-<key> | Custom content for a column's cells. Receives { row, value, column }. |
| toolbar | Extra controls beside the search field, e.g. bulk actions. Receives { selectedCount }. |
| empty | Replaces the default empty state. |
Component · Navigation
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.
Component · Hero sections
Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.
Component · Feature sections
Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.
Component · Layout