Vue componentFree

Data Table

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

Install
Full screen ↗

About this component

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.

  • Generic row type with typed columns and slots
  • Click-to-sort with aria-sort
  • Search, pagination and empty state
  • Checkbox selection with indeterminate select-all
  • Horizontal scroll on mobile

Code

11 files in the repo
<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>

Props

PropTypeDefaultDescription
rowsT[]12 sample invoicesRows to display. The row type is inferred and used for columns and slots.
columnsDataTableColumn<T>[]Invoice, Customer, Status, Issued, AmountColumn config: key, label, sortable, align ('left' | 'center' | 'right'), format(value, row) and class for body cells.
rowKeykeyof T'id'Property that uniquely identifies each row; used for keys and selection.
captionstring'Invoices'Title shown in the toolbar and used as the table caption for screen readers.
pageSizenumber5Rows per page. 0 disables pagination.
selectablebooleantrueShow a checkbox column with select-all.
filterablebooleantrueShow the search field that filters across all columns.
filterPlaceholderstring'Search…'Placeholder text for the search field.
emptyTextstring'No rows match your search.'Message shown when there are no rows to display.
selectedArray<T[keyof T]>undefinedSelected row keys. Use with v-model:selected, or leave unbound for internal state.
defaultSort{ key: string; direction: 'asc' | 'desc' }undefinedInitial sort column and direction.

Events

EventPayloadDescription
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).

Slots

SlotDescription
cell-<key>Custom content for a column's cells. Receives { row, value, column }.
toolbarExtra controls beside the search field, e.g. bulk actions. Receives { selectedCount }.
emptyReplaces the default empty state.
All data tables →
Preview of Pagination BarFree

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.

by Vueplay
Preview of Hero SplitFree

Component · Hero sections

Hero Split

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.

by Vueplay1 installs
Preview of Bento GridFree

Component · Feature sections

Bento Grid

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.

by Vueplay
Preview of BlockFree

Component · Layout

Block

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