Skip to content

Data Table ​

A sortable, selectable table whose rows glide into their new order and whose selection bar slides up.

Customers
Status
Amara Okafor
amara@lumen.io
Active$2,400Feb 2023
Jonas Lindqvist
jonas@fjord.se
Active$490Jun 2024
Priya Raman
priya@kitehq.com
Trial$0Jan 2025
Mateo García
mateo@sol.mx
Past due$490Nov 2023
Hana Sato
hana@origami.jp
Active$3,150Sep 2022
Leo Martin
leo@atelier.fr
Active$49Dec 2024
2 selected

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/data-table.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/data-table/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts" generic="Row extends Record<string, any>">
import type { HTMLAttributes } from 'vue'
import type { DataTableColumn, DataTableSort } from './types'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
import DataTableCheckbox from './DataTableCheckbox.vue'

type RowKey = string | number

const props = withDefaults(defineProps<{
  columns: DataTableColumn<Row>[]
  rows: Row[]
  /** Property (or function) that uniquely identifies a row. */
  rowKey?: keyof Row | ((row: Row) => RowKey)
  /** Adds a checkbox column and the selection bar. */
  selectable?: boolean
  /** Accessible caption (visually hidden). */
  caption?: string
  /** Shown when there are no rows; override with the `#empty` slot. */
  emptyText?: string
  class?: HTMLAttributes['class']
}>(), {
  rowKey: 'id',
  selectable: false,
  emptyText: 'No results.',
})

/** Keys of the selected rows. */
const selected = defineModel<RowKey[]>('selected', { default: () => [] })
/** Current sort, or `null` for the original order. */
const sort = defineModel<DataTableSort | null>('sort', { default: null })

const reduced = useReducedMotion()
const spring = { type: 'spring', stiffness: 420, damping: 36 } as const
const transition = computed(() => (reduced.value ? { duration: 0 } : spring))

function keyOf(row: Row): RowKey {
  return typeof props.rowKey === 'function' ? props.rowKey(row) : row[props.rowKey]
}

const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })

const sortedRows = computed(() => {
  const s = sort.value
  const column = s && props.columns.find(c => c.key === s.key)
  if (!s || !column)
    return props.rows
  const value = (row: Row) => (column.sortValue ? column.sortValue(row) : row[column.key])
  const dir = s.direction === 'asc' ? 1 : -1
  return [...props.rows].sort((a, b) => {
    const va = value(a)
    const vb = value(b)
    if (va == null || vb == null)
      return va == null ? (vb == null ? 0 : 1) : -1
    if (typeof va === 'number' && typeof vb === 'number')
      return (va - vb) * dir
    if (va instanceof Date && vb instanceof Date)
      return (va.getTime() - vb.getTime()) * dir
    return collator.compare(String(va), String(vb)) * dir
  })
})

// asc → desc → original order
function toggleSort(key: string) {
  if (sort.value?.key !== key)
    sort.value = { key, direction: 'asc' }
  else if (sort.value.direction === 'asc')
    sort.value = { key, direction: 'desc' }
  else
    sort.value = null
}

function ariaSort(column: DataTableColumn<Row>) {
  if (!column.sortable)
    return undefined
  if (sort.value?.key !== column.key)
    return 'none'
  return sort.value.direction === 'asc' ? 'ascending' : 'descending'
}

const selectedSet = computed(() => new Set(selected.value))
const visibleKeys = computed(() => props.rows.map(keyOf))
const selectedCount = computed(() => visibleKeys.value.filter(k => selectedSet.value.has(k)).length)
const allState = computed<boolean | 'mixed'>(() => {
  if (!selectedCount.value)
    return false
  return selectedCount.value === visibleKeys.value.length ? true : 'mixed'
})

function toggleRow(row: Row) {
  const key = keyOf(row)
  selected.value = selectedSet.value.has(key)
    ? selected.value.filter(k => k !== key)
    : [...selected.value, key]
}

function toggleAll() {
  selected.value = allState.value === true ? [] : [...visibleKeys.value]
}

function clear() {
  selected.value = []
}

const colspan = computed(() => props.columns.length + (props.selectable ? 1 : 0))
const alignClass = { left: 'text-left!', center: 'text-center!', right: 'text-right!' } as const
const justifyClass = { left: 'justify-start', center: 'justify-center', right: 'justify-end' } as const
</script>

<template>
  <div :class="cn('relative w-full overflow-hidden rounded-xl border bg-card text-card-foreground', props.class)">
    <div class="w-full overflow-x-auto">
      <!-- The `!` utilities keep global table styles (docs themes, typography
           plugins) from leaking into the table. -->
      <table class="m-0! table! w-full border-collapse text-sm">
        <caption v-if="props.caption" class="sr-only">
          {{ props.caption }}
        </caption>
        <thead class="border-b bg-muted/40">
          <tr class="border-t-0! bg-transparent!">
            <th v-if="props.selectable" scope="col" class="w-10 border-0! bg-transparent! py-2! pl-3! pr-0!">
              <DataTableCheckbox
                :checked="allState"
                :label="allState === true ? 'Deselect all rows' : 'Select all rows'"
                :reduced="reduced"
                :disabled="!visibleKeys.length"
                @toggle="toggleAll"
              />
            </th>
            <th
              v-for="column in props.columns"
              :key="column.key"
              scope="col"
              :aria-sort="ariaSort(column)"
              :class="cn('h-10 whitespace-nowrap border-0! bg-transparent! px-3! py-0! text-sm! font-medium! text-muted-foreground!', alignClass[column.align ?? 'left'], column.class)"
            >
              <button
                v-if="column.sortable"
                type="button"
                :class="cn(
                  '-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50',
                  sort?.key === column.key && 'text-foreground',
                  justifyClass[column.align ?? 'left'],
                )"
                @click="toggleSort(column.key)"
              >
                <slot :name="`header-${column.key}`" :column="column">
                  {{ column.label }}
                </slot>
                <motion.svg
                  class="size-3.5"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  stroke-width="2.25"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  aria-hidden="true"
                  :initial="false"
                  :animate="{
                    rotate: sort?.key === column.key && sort.direction === 'desc' ? 180 : 0,
                    opacity: sort?.key === column.key ? 1 : 0.35,
                  }"
                  :transition="transition"
                >
                  <path d="M12 19V5M6 11l6-6 6 6" />
                </motion.svg>
              </button>
              <slot v-else :name="`header-${column.key}`" :column="column">
                {{ column.label }}
              </slot>
            </th>
          </tr>
        </thead>
        <AnimatePresence as="tbody" :initial="false">
          <motion.tr
            v-for="row in sortedRows"
            :key="keyOf(row)"
            layout="position"
            :data-state="selectedSet.has(keyOf(row)) ? 'selected' : undefined"
            :class="cn(
              'border-b border-t-0! bg-transparent! transition-colors! duration-150! last:border-b-0 hover:bg-muted/50!',
              selectedSet.has(keyOf(row)) && 'bg-muted/70! hover:bg-muted!',
            )"
            :exit="{ opacity: 0 }"
            :transition="transition"
          >
            <td v-if="props.selectable" class="w-10 border-0! py-2! pl-3! pr-0!">
              <DataTableCheckbox
                :checked="selectedSet.has(keyOf(row))"
                :label="`Select row ${keyOf(row)}`"
                :reduced="reduced"
                @toggle="toggleRow(row)"
              />
            </td>
            <td
              v-for="column in props.columns"
              :key="column.key"
              :class="cn('border-0! px-3! py-2.5!', alignClass[column.align ?? 'left'], column.class)"
            >
              <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
                {{ row[column.key] }}
              </slot>
            </td>
          </motion.tr>
          <motion.tr
            v-if="!sortedRows.length"
            key="vx-empty"
            :initial="{ opacity: 0 }"
            :animate="{ opacity: 1 }"
            :transition="{ delay: 0.15 }"
          >
            <td :colspan="colspan" class="border-0! px-3! py-12! text-center text-sm text-muted-foreground">
              <slot name="empty">
                {{ props.emptyText }}
              </slot>
            </td>
          </motion.tr>
        </AnimatePresence>
      </table>
    </div>

    <AnimatePresence :initial="false">
      <motion.div
        v-if="props.selectable && selectedCount > 0"
        key="bar"
        class="overflow-hidden"
        :initial="{ height: 0 }"
        :animate="{ height: 'auto' }"
        :exit="{ height: 0 }"
        :transition="transition"
      >
        <motion.div
          role="status"
          class="flex items-center gap-3 border-t bg-muted/40 px-3 py-2"
          :initial="{ y: '100%', opacity: 0 }"
          :animate="{ y: 0, opacity: 1 }"
          :exit="{ y: '100%', opacity: 0 }"
          :transition="transition"
        >
          <span class="text-sm font-medium tabular-nums">
            {{ selectedCount }} selected
          </span>
          <button
            type="button"
            class="rounded-md px-2 py-1 text-xs text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
            @click="clear"
          >
            Clear
          </button>
          <div class="ml-auto flex items-center gap-2">
            <slot name="actions" :selected="selected" :clear="clear" />
          </div>
        </motion.div>
      </motion.div>
    </AnimatePresence>
  </div>
</template>
vue
<script setup lang="ts">
import { motion } from 'motion-v'
import { cn } from '@/lib/utils'

const props = defineProps<{
  checked: boolean | 'mixed'
  label: string
  reduced?: boolean
  disabled?: boolean
}>()

const emit = defineEmits<{ toggle: [] }>()

const pop = { type: 'spring', stiffness: 600, damping: 22 } as const
</script>

<template>
  <button
    type="button"
    role="checkbox"
    :aria-checked="props.checked"
    :aria-label="props.label"
    :disabled="props.disabled"
    class="group/check relative flex size-6 items-center justify-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40"
    @click.stop="emit('toggle')"
  >
    <motion.span
      aria-hidden="true"
      :class="cn(
        'flex size-4 items-center justify-center rounded-[5px] border transition-colors duration-150',
        props.checked ? 'border-primary bg-primary text-primary-foreground' : 'border-input bg-background group-hover/check:border-ring',
      )"
      :while-press="props.reduced ? undefined : { scale: 0.82 }"
      :transition="pop"
    >
      <svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
        <motion.path
          v-if="props.checked === 'mixed'"
          d="M6 12h12"
          :initial="{ pathLength: 0 }"
          :animate="{ pathLength: 1 }"
          :transition="props.reduced ? { duration: 0 } : { duration: 0.18 }"
        />
        <motion.path
          v-else
          d="M5 12.5 10 17.5 19 7"
          :initial="false"
          :animate="{ pathLength: props.checked ? 1 : 0, opacity: props.checked ? 1 : 0 }"
          :transition="props.reduced ? { duration: 0 } : { duration: 0.22, ease: 'easeOut' }"
        />
      </svg>
    </motion.span>
  </button>
</template>
ts
import type { HTMLAttributes } from 'vue'

export interface DataTableColumn<R = Record<string, any>> {
  /** Property of the row shown in this column (also the `#cell-<key>` slot name). */
  key: string
  label: string
  sortable?: boolean
  align?: 'left' | 'center' | 'right'
  /** Value used for sorting when it differs from the displayed one. */
  sortValue?: (row: R) => string | number | Date | null | undefined
  /** Extra classes for the header and body cells of this column. */
  class?: HTMLAttributes['class']
}

export interface DataTableSort {
  key: string
  direction: 'asc' | 'desc'
}
ts
export { default as DataTable } from './DataTable.vue'
export type { DataTableColumn, DataTableSort } from './types'

Usage ​

ts
import { DataTable, type DataTableColumn } from '@/components/ui/data-table'
vue
<DataTable
  v-model:selected="selected"
  :columns="[
    { key: 'name', label: 'Name', sortable: true },
    { key: 'amount', label: 'Amount', sortable: true, align: 'right' },
  ]"
  :rows="invoices"
  selectable
>
  <template #cell-amount="{ value }">{{ money.format(value) }}</template>
  <template #actions="{ selected, clear }">
    <button @click="archive(selected); clear()">Archive</button>
  </template>
</DataTable>

API ​

PropTypeDefaultDescription
columnsDataTableColumn[]—{ key, label, sortable?, align?, sortValue?, class? }.
rowsRow[]—Row objects.
rowKeykeyof Row | (row) => string | number'id'Unique key per row; used for selection and animation.
selectablebooleanfalseAdds the checkbox column and the selection bar.
v-model:selected(string | number)[][]Keys of the selected rows.
v-model:sort{ key, direction } | nullnullCurrent sort. Clicking a header cycles ascending → descending → original order.
captionstring—Visually hidden table caption.
emptyTextstring'No results.'Shown when rows is empty.
SlotPropsDescription
cell-<key>{ row, value }Custom cell for the column with that key.
header-<key>{ column }Custom header label.
actions{ selected, clear }Buttons in the selection bar.
empty—Empty state.

Sorting uses a numeric, locale-aware compare (numbers, dates and strings). Sortable headers are buttons inside <th aria-sort>, and checkboxes are role="checkbox" buttons with a mixed state on the header. Rows animate with layout="position"; with prefers-reduced-motion they move instantly.

Designed by Kuldeep Pisda · Released under the MIT License