Keyboard shortcut: Ctrl + K
Get started

Data table

Rows with searching, sorting, selection and pagination. It does all four itself over the rows it is given, or hands them to a server and simply reports what is being asked for.

Usage

vue
Projects
ProjectOwnerCommits
Vectis UIAda Lovelace320
AtlasGrace Hopper87
MeridianAlan Turing44
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project' },
  { key: 'owner', label: 'Owner' },
  { key: 'commits', label: 'Commits' },
]

const rows = [
  { name: 'Vectis UI', owner: 'Ada Lovelace', commits: 320 },
  { name: 'Atlas', owner: 'Grace Hopper', commits: 87 },
  { name: 'Meridian', owner: 'Alan Turing', commits: 44 },
]
</script>

<template>
  <VDataTable :columns="columns" :rows="rows" row-key="name" title="Projects" />
</template>

Examples

Sorting

A column marked sortable gets a clickable heading, cycling ascending, descending, then the order the rows were given. v-model:sort reads and sets that state.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
BrumeLouisArchived1204
VectisXavierActive320
AtlasNadiaActive87
GranitEmmaActive45

Sorted by commits, descending

<script setup lang="ts">
import { ref } from 'vue'
import { VDataTable, type DataTableSort } from 'vectis-ui'

/* `sortable` turns a heading into a button and the table does the sorting itself, on the
   values as they are given. The ascending icon points down, the spreadsheet convention. */
const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status', sortable: true },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier', status: 'Active', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', status: 'Active', commits: 87 },
  { name: 'Brume', owner: 'Louis', status: 'Archived', commits: 1204 },
  { name: 'Granit', owner: 'Emma', status: 'Active', commits: 45 },
]

/* The sort is a model, so the table can open on a column already sorted, and what the
   reader clicks can be read back. */
const sort = ref<DataTableSort | null>({ key: 'commits', direction: 'desc' })

const DIRECTIONS = { asc: 'ascending', desc: 'descending' }
</script>

<template>
  <div class="demo">
    <VDataTable
      v-model:sort="sort"
      :columns="columns"
      :rows="rows"
      row-key="name"
      title="Projects"
      caption="Organisation projects"
    />
    <p class="state">
      Sorted by {{ sort ? `${sort.key}, ${DIRECTIONS[sort.direction]}` : 'nothing' }}
    </p>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-3);
}
.state {
  margin: 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-body-sm-size);
}
</style>

searchable puts a field in the toolbar, which searches the declared columns while ignoring case and accents. v-model:search drives the term from elsewhere on the page.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
VectisXavierActive320
AtlasNadiaActive87
BrumeLouisArchived1204
GranitEmmaActive45
ÉclairXavierActive296
FalaiseNadiaArchived133
GivreLouisActive58
HouleEmmaActive411
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', align: 'end' as const },
]

/* `Éclair` keeps its accent: the search ignores diacritics, so eclair finds it. */
const rows = [
  { name: 'Vectis', owner: 'Xavier', status: 'Active', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', status: 'Active', commits: 87 },
  { name: 'Brume', owner: 'Louis', status: 'Archived', commits: 1204 },
  { name: 'Granit', owner: 'Emma', status: 'Active', commits: 45 },
  { name: 'Éclair', owner: 'Xavier', status: 'Active', commits: 296 },
  { name: 'Falaise', owner: 'Nadia', status: 'Archived', commits: 133 },
  { name: 'Givre', owner: 'Louis', status: 'Active', commits: 58 },
  { name: 'Houle', owner: 'Emma', status: 'Active', commits: 411 },
]
</script>

<template>
  <VDataTable
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    searchable
    search-placeholder="Search projects"
    search-label="Search the projects"
    caption="Organisation projects"
  />
</template>

Pagination

Any v-model:per-page above zero turns the pagination on. showRange adds the row count beside the nav.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
VectisXavierArchived111
AtlasNadiaActive148
BrumeLouisActive185
GranitEmmaArchived222
ÉclairXavierActive259
FalaiseNadiaActive296
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const NAMES = [
  'Vectis',
  'Atlas',
  'Brume',
  'Granit',
  'Éclair',
  'Falaise',
  'Givre',
  'Houle',
  'Islet',
  'Jade',
  'Karst',
  'Lande',
  'Mistral',
  'Nacre',
  'Ombre',
  'Pollen',
  'Quartz',
  'Rivage',
  'Sillage',
  'Tuile',
  'Vigie',
  'Zenith',
]
const OWNERS = ['Xavier', 'Nadia', 'Louis', 'Emma']

const rows = NAMES.map((name, index) => ({
  name,
  owner: OWNERS[index % OWNERS.length],
  status: index % 3 === 0 ? 'Archived' : 'Active',
  commits: ((index + 3) * 37) % 500,
}))
</script>

<template>
  <!-- A page size is all it takes: passing one down, bound or not, turns the pagination on. -->
  <VDataTable
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    searchable
    :per-page="6"
    show-range
    caption="Organisation projects"
  />
</template>

Rows per page

perPageOptions adds a menu to the footer for choosing how many rows a page holds. v-model:per-page reports what was chosen.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
VectisXavierArchived111
AtlasNadiaActive148
BrumeLouisActive185
GranitEmmaArchived222
ÉclairXavierActive259
<script setup lang="ts">
import { ref } from 'vue'
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const NAMES = [
  'Vectis',
  'Atlas',
  'Brume',
  'Granit',
  'Éclair',
  'Falaise',
  'Givre',
  'Houle',
  'Islet',
  'Jade',
  'Karst',
  'Lande',
  'Mistral',
  'Nacre',
  'Ombre',
  'Pollen',
  'Quartz',
  'Rivage',
  'Sillage',
  'Tuile',
  'Vigie',
  'Zenith',
]
const OWNERS = ['Xavier', 'Nadia', 'Louis', 'Emma']

const rows = NAMES.map((name, index) => ({
  name,
  owner: OWNERS[index % OWNERS.length],
  status: index % 3 === 0 ? 'Archived' : 'Active',
  commits: ((index + 3) * 37) % 500,
}))

const perPage = ref(5)
</script>

<template>
  <VDataTable
    v-model:per-page="perPage"
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    :per-page-options="[5, 10, 25]"
    show-range
    caption="Organisation projects"
  />
</template>

Selection

selectable adds a checkbox to every row and one to the heading for the visible page. rowKey is required with it, and v-model:selected holds the identities that field gives.

vue
Projects
Organisation projects
Select every project on this pageProjectOwnerStatusCommits
VectisXavierActive320
AtlasNadiaActive87
BrumeLouisArchived1204
GranitEmmaActive45
ÉclairXavierActive296

Selected: Atlas

<script setup lang="ts">
import { ref } from 'vue'
import { VDataTable, type DataTableRowId } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier', status: 'Active', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', status: 'Active', commits: 87 },
  { name: 'Brume', owner: 'Louis', status: 'Archived', commits: 1204 },
  { name: 'Granit', owner: 'Emma', status: 'Active', commits: 45 },
  { name: 'Éclair', owner: 'Xavier', status: 'Active', commits: 296 },
]

type Project = (typeof rows)[number]

/* What comes back are the identities `rowKey` names, never the row objects. */
const selected = ref<DataTableRowId[]>(['Atlas'])

/* "Select row" says nothing about which one, so the row itself names its checkbox. */
const selectRowLabel = (row: Project) => `Select ${row.name}`
</script>

<template>
  <div class="demo">
    <VDataTable
      v-model:selected="selected"
      :columns="columns"
      :rows="rows"
      row-key="name"
      title="Projects"
      selectable
      :select-row-label="selectRowLabel"
      select-all-label="Select every project on this page"
      caption="Organisation projects"
    />
    <p class="state">Selected: {{ selected.length ? selected.join(', ') : 'nothing' }}</p>
  </div>
</template>

<style scoped>
.demo {
  display: grid;
  gap: var(--vectis-space-3);
}
.state {
  margin: 0;
  color: var(--vectis-color-text-muted);
  font-size: var(--vectis-text-body-sm-size);
}
</style>

Toolbar

The #title slot replaces the title prop and takes the left of the toolbar, the search field keeping the right.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
VectisXavierActive320
AtlasNadiaActive87
BrumeLouisArchived1204
GranitEmmaActive45
ÉclairXavierArchived296
<script setup lang="ts">
import { computed, ref } from 'vue'
import { VButton, VDataTable, VMenu, VMenuItem } from 'vectis-ui'
import { expand_more as expandMore } from 'vectis-ui/icons'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier', status: 'Active', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', status: 'Active', commits: 87 },
  { name: 'Brume', owner: 'Louis', status: 'Archived', commits: 1204 },
  { name: 'Granit', owner: 'Emma', status: 'Active', commits: 45 },
  { name: 'Éclair', owner: 'Xavier', status: 'Archived', commits: 296 },
]

/* The filtering is yours: the table shows the rows it is given, and the search field it
   provides narrows them further. */
const status = ref('All')
const filtered = computed(() =>
  status.value === 'All' ? rows : rows.filter((row) => row.status === status.value),
)
</script>

<template>
  <VDataTable
    :columns="columns"
    :rows="filtered"
    row-key="name"
    searchable
    caption="Organisation projects"
  >
    <template #title>
      <div class="toolbar">
        <span>Projects</span>
        <VMenu match-trigger>
          <template #trigger="{ triggerProps }">
            <VButton
              v-bind="triggerProps"
              variant="outline"
              tone="neutral"
              size="sm"
              :icon-end="expandMore"
            >
              Status: {{ status }}
            </VButton>
          </template>
          <VMenuItem
            v-for="option in ['All', 'Active', 'Archived']"
            :key="option"
            :label="option"
            @select="status = option"
          />
        </VMenu>
        <VButton size="sm">New project</VButton>
      </div>
    </template>
  </VDataTable>
</template>

<style scoped>
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-3);
}
</style>

Custom cells

A slot named after a column key replaces what that column's cells show, and receives the row, the raw value and the column. Searching and sorting still read the underlying value.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
Vectis Xavier DarmetActive320
Atlas Nadia RousseauActive87
Brume Louis FabreArchived1,204
Granit Emma LefortActive45
<script setup lang="ts">
import { VAvatar, VChip, VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier Darmet', status: 'Active', commits: 320 },
  { name: 'Atlas', owner: 'Nadia Rousseau', status: 'Active', commits: 87 },
  { name: 'Brume', owner: 'Louis Fabre', status: 'Archived', commits: 1204 },
  { name: 'Granit', owner: 'Emma Lefort', status: 'Active', commits: 45 },
]

const numbers = new Intl.NumberFormat('en-GB')
</script>

<template>
  <VDataTable
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    caption="Organisation projects"
  >
    <!-- A cell slot is named after its column key, and receives the whole row. -->
    <template #cell-owner="{ row }">
      <span class="owner">
        <VAvatar :name="row.owner" size="xs" />
        {{ row.owner }}
      </span>
    </template>

    <template #cell-status="{ row }">
      <VChip :tone="row.status === 'Active' ? 'success' : 'neutral'" size="xs">
        {{ row.status }}
      </VChip>
    </template>

    <!-- Sorting reads the underlying value, so a formatted number still sorts as a number. -->
    <template #cell-commits="{ row }">
      <span class="figure">{{ numbers.format(row.commits) }}</span>
    </template>
  </VDataTable>
</template>

<style scoped>
.owner {
  display: inline-flex;
  align-items: center;
  gap: var(--vectis-space-2);
}
.figure {
  font-variant-numeric: tabular-nums;
}
</style>

Custom headings

A slot named head- plus the column key replaces a heading. On a sortable column it renders inside the sort button, so keep it to text and decoration.

vue
Projects
Organisation projects
ProjectOwnerLast updateCommits
VectisXavier2 days ago320
AtlasNadia3 weeks ago87
BrumeLouisYesterday1204
GranitEmmaAn hour ago45
<script setup lang="ts">
import { VDataTable, VIcon } from 'vectis-ui'
import { code, schedule } from 'vectis-ui/icons'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'updated', label: 'Last update' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier', updated: '2 days ago', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', updated: '3 weeks ago', commits: 87 },
  { name: 'Brume', owner: 'Louis', updated: 'Yesterday', commits: 1204 },
  { name: 'Granit', owner: 'Emma', updated: 'An hour ago', commits: 45 },
]
</script>

<template>
  <VDataTable
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    caption="Organisation projects"
  >
    <template #head-updated="{ column }">
      <span class="heading"><VIcon :name="schedule" :size="16" />{{ column.label }}</span>
    </template>

    <!-- On a sortable column the slot renders inside the sort button, so keep it to text
         and decoration: a control there would be a control inside a control. -->
    <template #head-commits="{ column }">
      <span class="heading"><VIcon :name="code" :size="16" />{{ column.label }}</span>
    </template>
  </VDataTable>
</template>

<style scoped>
.heading {
  display: inline-flex;
  align-items: center;
  gap: var(--vectis-space-1);
}
</style>

Variants

variant sets the decoration: flat carries none, outlined adds a raised background, a border, rounded corners and a gutter around the toolbar, the caption and the footer.

vue
Flat
Organisation projects, flat
ProjectOwnerCommits
VectisXavier320
AtlasNadia87
BrumeLouis1204
Outlined
Organisation projects, outlined
ProjectOwnerCommits
VectisXavier320
AtlasNadia87
BrumeLouis1204
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', commits: 87 },
  { name: 'Brume', owner: 'Louis', commits: 1204 },
]
</script>

<template>
  <div class="stack">
    <!-- Flat carries no decoration at all and sits on whatever surface it is placed on. -->
    <VDataTable
      :columns="columns"
      :rows="rows"
      row-key="name"
      title="Flat"
      searchable
      caption="Organisation projects, flat"
    />

    <!-- Outlined makes it a card, and the frame gives the toolbar and the footer a gutter. -->
    <VDataTable
      variant="outlined"
      :columns="columns"
      :rows="rows"
      row-key="name"
      title="Outlined"
      searchable
      caption="Organisation projects, outlined"
    />
  </div>
</template>

<style scoped>
.stack {
  display: grid;
  gap: var(--vectis-space-6);
}
</style>

Compact

compact tightens every cell, and the search field, the page size menu and the pagination with them.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
VectisXavierActive320
AtlasNadiaActive87
BrumeLouisArchived1204
GranitEmmaActive45
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier', status: 'Active', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', status: 'Active', commits: 87 },
  { name: 'Brume', owner: 'Louis', status: 'Archived', commits: 1204 },
  { name: 'Granit', owner: 'Emma', status: 'Active', commits: 45 },
  { name: 'Éclair', owner: 'Xavier', status: 'Active', commits: 296 },
  { name: 'Falaise', owner: 'Nadia', status: 'Archived', commits: 133 },
]
</script>

<template>
  <!-- The cells tighten, and so does everything the table renders with them: the search
       field, the page size menu and the pagination all take the shorter step. -->
  <VDataTable
    compact
    variant="outlined"
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    searchable
    :per-page="4"
    show-range
    caption="Organisation projects"
  />
</template>

Striped rows

striped tints every other row.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
VectisXavierActive320
AtlasNadiaActive87
BrumeLouisArchived1204
GranitEmmaActive45
ÉclairXavierActive296
FalaiseNadiaArchived133
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier', status: 'Active', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', status: 'Active', commits: 87 },
  { name: 'Brume', owner: 'Louis', status: 'Archived', commits: 1204 },
  { name: 'Granit', owner: 'Emma', status: 'Active', commits: 45 },
  { name: 'Éclair', owner: 'Xavier', status: 'Active', commits: 296 },
  { name: 'Falaise', owner: 'Nadia', status: 'Archived', commits: 133 },
]
</script>

<template>
  <VDataTable
    striped
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    caption="Organisation projects"
  />
</template>

stickyHeader keeps the column headings in place while the rows scroll. It needs a bounded scrolling area, either the height prop or a parent with a height of its own.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
VectisXavierArchived111
AtlasNadiaActive148
BrumeLouisActive185
GranitEmmaArchived222
ÉclairXavierActive259
FalaiseNadiaActive296
GivreLouisArchived333
HouleEmmaActive370
IsletXavierActive407
JadeNadiaArchived444
KarstLouisActive481
LandeEmmaActive18
MistralXavierArchived55
NacreNadiaActive92
OmbreLouisActive129
PollenEmmaArchived166
QuartzXavierActive203
RivageNadiaActive240
SillageLouisArchived277
TuileEmmaActive314
VigieXavierActive351
ZenithNadiaArchived388
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const NAMES = [
  'Vectis',
  'Atlas',
  'Brume',
  'Granit',
  'Éclair',
  'Falaise',
  'Givre',
  'Houle',
  'Islet',
  'Jade',
  'Karst',
  'Lande',
  'Mistral',
  'Nacre',
  'Ombre',
  'Pollen',
  'Quartz',
  'Rivage',
  'Sillage',
  'Tuile',
  'Vigie',
  'Zenith',
]
const OWNERS = ['Xavier', 'Nadia', 'Louis', 'Emma']

const rows = NAMES.map((name, index) => ({
  name,
  owner: OWNERS[index % OWNERS.length],
  status: index % 3 === 0 ? 'Archived' : 'Active',
  commits: ((index + 3) * 37) % 500,
}))
</script>

<template>
  <!-- The headings need something to stay put against, so the scrolling area has to be
       bounded: here by `height`, otherwise by a parent with a height of its own. -->
  <VDataTable
    sticky-header
    variant="outlined"
    :height="320"
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    caption="Organisation projects"
  />
</template>

Full height

height bounds the whole component, toolbar and footer included, a number being read as pixels. Left out, the table takes its parent's height whenever the parent has one.

vue
Projects
Organisation projects
ProjectOwnerStatusCommits
VectisXavierArchived111
AtlasNadiaActive148
BrumeLouisActive185
GranitEmmaArchived222
ÉclairXavierActive259
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const NAMES = [
  'Vectis',
  'Atlas',
  'Brume',
  'Granit',
  'Éclair',
  'Falaise',
  'Givre',
  'Houle',
  'Islet',
  'Jade',
  'Karst',
  'Lande',
]
const OWNERS = ['Xavier', 'Nadia', 'Louis', 'Emma']

const rows = NAMES.map((name, index) => ({
  name,
  owner: OWNERS[index % OWNERS.length],
  status: index % 3 === 0 ? 'Archived' : 'Active',
  commits: ((index + 3) * 37) % 500,
}))
</script>

<template>
  <!-- The panel has a height, so the table takes it: the toolbar and the footer keep their
       places and only the rows scroll, whatever the page holds. Change the page size and
       the footer stays where it is. -->
  <div class="panel">
    <VDataTable
      variant="outlined"
      sticky-header
      :columns="columns"
      :rows="rows"
      row-key="name"
      title="Projects"
      searchable
      :per-page="5"
      :per-page-options="[5, 10]"
      show-range
      caption="Organisation projects"
    />
  </div>
</template>

<style scoped>
.panel {
  block-size: 26rem;
}
</style>

Narrow containers

responsive decides what a container too narrow for the columns does: scroll sideways, or turn each row into a card with its column headings repeated inside it. As cards, the heading row is out of sight, so its sort buttons and its "select all" box leave the tab order with it.

vue
Projects
Organisation projects
Select allProjectOwnerStatusCommits
VectisXavierActive320
AtlasNadiaActive87
BrumeLouisArchived1204
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const rows = [
  { name: 'Vectis', owner: 'Xavier', status: 'Active', commits: 320 },
  { name: 'Atlas', owner: 'Nadia', status: 'Active', commits: 87 },
  { name: 'Brume', owner: 'Louis', status: 'Archived', commits: 1204 },
]
</script>

<template>
  <!-- The threshold is the COMPONENT's own width, not the window's, so the box below is
       resizable: drag its corner past 640px and the cards become a table again. -->
  <div class="box">
    <VDataTable
      responsive="stack"
      variant="outlined"
      :columns="columns"
      :rows="rows"
      row-key="name"
      title="Projects"
      selectable
      caption="Organisation projects"
    />
  </div>
</template>

<style scoped>
.box {
  overflow: auto;
  inline-size: 24rem;
  min-inline-size: 15rem;
  max-inline-size: 100%;
  padding: var(--vectis-space-1);
  resize: horizontal;
}
</style>

Server side

serverSide hands the searching, the sorting and the paging over: the rows are shown as they arrive and update:params reports every change. Pass total for the pagination and the range, and searchDebounce to delay the search.

vue
Projects
Organisation projects, a page at a time
ProjectOwnerStatusCommits
Loading data…
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { VDataTable, type DataTableParams } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner' },
  { key: 'status', label: 'Status' },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const NAMES = [
  'Vectis',
  'Atlas',
  'Brume',
  'Granit',
  'Éclair',
  'Falaise',
  'Givre',
  'Houle',
  'Islet',
  'Jade',
  'Karst',
  'Lande',
  'Mistral',
  'Nacre',
  'Ombre',
  'Pollen',
  'Quartz',
  'Rivage',
  'Sillage',
  'Tuile',
  'Vigie',
  'Zenith',
]
const OWNERS = ['Xavier', 'Nadia', 'Louis', 'Emma']

/* What a database would hold. Nothing here is ever handed to the table whole. */
const DATA = NAMES.map((name, index) => ({
  name,
  owner: OWNERS[index % OWNERS.length],
  status: index % 3 === 0 ? 'Archived' : 'Active',
  commits: ((index + 3) * 37) % 500,
}))

type Project = (typeof DATA)[number]

const rows = ref<Project[]>([])
const total = ref(0)
const loading = ref(true)

/* A stand-in for the request. In server mode the table filters, sorts and slices nothing,
   so everything below is the answering end's work. */
function load(params: DataTableParams) {
  loading.value = true

  window.setTimeout(() => {
    const term = params.search.trim().toLowerCase()
    const matching = term
      ? DATA.filter((row) => `${row.name} ${row.owner} ${row.status}`.toLowerCase().includes(term))
      : DATA

    const key = params.sortKey as keyof Project | null
    const sorted = key
      ? [...matching].sort(
          (a, b) =>
            String(a[key]).localeCompare(String(b[key]), 'en', { numeric: true }) *
            (params.sortDirection === 'desc' ? -1 : 1),
        )
      : matching

    const size = params.perPage ?? sorted.length
    const start = (params.page - 1) * size

    rows.value = sorted.slice(start, start + size)
    total.value = sorted.length
    loading.value = false
  }, 600)
}

/* Nothing is emitted when the table appears, so the first page is asked for here. */
onMounted(() => load({ page: 1, perPage: 5, sortKey: null, sortDirection: null, search: '' }))
</script>

<template>
  <VDataTable
    server-side
    variant="outlined"
    :columns="columns"
    :rows="rows"
    :total="total"
    :loading="loading"
    row-key="name"
    title="Projects"
    searchable
    :per-page="5"
    :per-page-options="[5, 10]"
    show-range
    caption="Organisation projects, a page at a time"
    @update:params="load"
  />
</template>

Loading and empty

loading shows a spinner and loadingText in place of the rows, and is answered before emptiness. emptyText is what the table says when there is nothing to show. The #loading and #empty slots replace either state, the second receiving the search that emptied the table.

vue
Loading
Projects being loaded
ProjectOwnerCommits
Loading data…
Nothing to show
Projects, none of them
ProjectOwnerCommits
No project yet
<script setup lang="ts">
import { VDataTable } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project' },
  { key: 'owner', label: 'Owner' },
  { key: 'commits', label: 'Commits', align: 'end' as const },
]
</script>

<template>
  <div class="stack">
    <!-- Loading is answered before emptiness, so a table waiting for its rows never
         claims there are none. -->
    <VDataTable
      loading
      variant="outlined"
      :columns="columns"
      :rows="[]"
      title="Loading"
      caption="Projects being loaded"
    />

    <VDataTable
      variant="outlined"
      :columns="columns"
      :rows="[]"
      empty-text="No project yet"
      title="Nothing to show"
      caption="Projects, none of them"
    />
  </div>
</template>

<style scoped>
.stack {
  display: grid;
  gap: var(--vectis-space-6);
}
</style>

A complete table

Everything at once: a title, a search, a selection, four sortable columns, cells of its own, and a footer carrying the selection count, the page size, the range and the pagination.

vue
Projects
Every project in the organisation
Select allProjectOwnerStatusCommits
Vectis Xavier DarmetArchived111
Atlas Nadia RousseauActive148
Brume Louis FabreActive185
Granit Emma LefortArchived222
Éclair Xavier DarmetActive259
Falaise Nadia RousseauActive296
Givre Louis FabreArchived333
Houle Emma LefortActive370
<script setup lang="ts">
import { ref } from 'vue'
import { VAvatar, VChip, VDataTable, type DataTableRowId } from 'vectis-ui'

const columns = [
  { key: 'name', label: 'Project', sortable: true },
  { key: 'owner', label: 'Owner', sortable: true },
  { key: 'status', label: 'Status', sortable: true },
  { key: 'commits', label: 'Commits', sortable: true, align: 'end' as const },
]

const NAMES = [
  'Vectis',
  'Atlas',
  'Brume',
  'Granit',
  'Éclair',
  'Falaise',
  'Givre',
  'Houle',
  'Islet',
  'Jade',
  'Karst',
  'Lande',
  'Mistral',
  'Nacre',
  'Ombre',
  'Pollen',
  'Quartz',
  'Rivage',
  'Sillage',
  'Tuile',
  'Vigie',
  'Zenith',
]
const OWNERS = ['Xavier Darmet', 'Nadia Rousseau', 'Louis Fabre', 'Emma Lefort']

const rows = NAMES.map((name, index) => ({
  name,
  owner: OWNERS[index % OWNERS.length],
  status: index % 3 === 0 ? 'Archived' : 'Active',
  commits: ((index + 3) * 37) % 500,
}))

type Project = (typeof rows)[number]

const selected = ref<DataTableRowId[]>([])
const numbers = new Intl.NumberFormat('en-GB')

const selectRowLabel = (row: Project) => `Select ${row.name}`
const selectionText = (count: number) => `${count} project${count > 1 ? 's' : ''} selected`
</script>

<template>
  <VDataTable
    v-model:selected="selected"
    variant="outlined"
    :columns="columns"
    :rows="rows"
    row-key="name"
    title="Projects"
    searchable
    search-placeholder="Search projects"
    selectable
    :select-row-label="selectRowLabel"
    :selection-text="selectionText"
    sticky-header
    :height="420"
    :per-page="8"
    :per-page-options="[8, 16, 24]"
    show-range
    caption="Every project in the organisation"
  >
    <template #cell-owner="{ row }">
      <span class="owner">
        <VAvatar :name="row.owner" size="xs" />
        {{ row.owner }}
      </span>
    </template>

    <template #cell-status="{ row }">
      <VChip :tone="row.status === 'Active' ? 'success' : 'neutral'" size="xs">
        {{ row.status }}
      </VChip>
    </template>

    <template #cell-commits="{ row }">
      <span class="figure">{{ numbers.format(row.commits) }}</span>
    </template>
  </VDataTable>
</template>

<style scoped>
.owner {
  display: inline-flex;
  align-items: center;
  gap: var(--vectis-space-2);
}
.figure {
  font-variant-numeric: tabular-nums;
}
</style>

API

Props

PropTypeDefault
columnsDataTableColumn[]none
The columns to show, in order.
rowsRow[]none
The rows to show.
rowKeystringnone
Which field identifies a row. Without it a row is identified by its position, which is enough for display but not for a selection: it must be given as soon as rows can be selected, or the selection follows the positions rather than the rows.
captionstringnone
A sentence describing what the table holds. It is announced before the table itself, and is what tells a screen reader user whether it is worth exploring.
variantDataTableVariant'flat' | 'outlined''flat'
How the table is framed: nothing at all, or a card with a raised background, a border and rounded corners.
responsiveDataTableResponsive'scroll' | 'stack''scroll'
What happens when the component is too narrow: the table scrolls sideways, or each row becomes a card with its column headings repeated inside it.
loadingbooleanfalse
Shows that the rows are being loaded.
loadingTextstringnone
What is written beside the spinner while the rows are loading. It falls back to the design system dictionary.
emptyTextstringnone
What is said when there is no row to show. It falls back to the design system dictionary.
titlestringnone
A title above the table, on the left of its toolbar. With no caption it also names the table for screen readers. It shadows the HTML attribute of the same name on the component itself, an accepted trade-off: a tooltip over a whole table would be of little use.
searchablebooleanfalse
Adds a search field to the toolbar.
searchPlaceholderstringnone
What that field says while empty. It falls back to the design system dictionary.
searchLabelstringnone
What screen readers announce for the search field. It falls back to the design system dictionary.
searchDebouncenumber250
When a server does the searching, how long to wait after a keystroke before asking it, in milliseconds. Zero asks at once.
stripedbooleanfalse
Tints every other row, which helps the eye follow a long line across the table.
stickyHeaderbooleanfalse
Keeps the column headings in place while the rows scroll under them. It needs a bounded scrolling area to work: either the height prop, or a parent with a height of its own.
compactbooleanfalse
Tightens the cells by one step, and everything the table renders with them.
heightnumber | stringnone
The height of the whole component, toolbar and pagination included: a number is read as pixels, anything else as a CSS length. Left out, the table takes its parent's height whenever the parent has one.
sortIconIconSourceswap_vert
The heading icon of a column that can be sorted but currently is not.
sortAscIconIconSourcearrow_downward
The icon of an ascending sort. It points down by default, the spreadsheet convention: sorting A to Z reads downwards.
sortDescIconIconSourcearrow_upward
The icon of a descending sort.
perPageOptionsnumber[]none
The choices offered for how many rows a page holds.
perPageTextstringnone
What that choice is called. It falls back to the design system dictionary.
totalnumbernone
How many rows there are in all on the server. It is what lets the pagination and the range be right when the table only ever holds one page.
showRangebooleanfalse
Shows which rows are being looked at, "1 to 10 of 42", in the footer.
rangeText(range: DataTableRange) => stringnone
Rephrases that range. It falls back to the design system dictionary.
selectablebooleanfalse
Adds a checkbox to every row, and one in the heading to take the whole page.
selectAllLabelstringnone
What the heading checkbox is announced as. It falls back to the design system dictionary.
selectionText(count: number) => stringnone
How the selection is summed up in the footer. It says nothing at all when nothing is selected, and falls back to the design system dictionary.
selectRowLabel(row: Row, index: number) => stringnone
What a row's checkbox is announced as. "Select row" tells a screen reader user nothing about which row, so this is worth supplying with something from the row itself. index is the row's position in the whole table, from 0, not in the page. It falls back to the design system dictionary, which numbers the rows from 1.
serverSidebooleanfalse
Hands the searching, the sorting and the paging over to a server: the rows are shown exactly as they arrive, and every change of what is being asked for is reported so the server can answer it.
v-model:sortDataTableSort | nullnull
Which column the rows are sorted by, and in which direction. Nothing is sorted to begin with. It may be driven from outside or simply left to the table, which sets it as headers are clicked; changing it does not send the reader back to the first page.
v-model:pagenumber1
The page being shown, counted from 1. Searching or changing the page size, from the menu or from outside, sends it back to the first. It is clamped by derivation rather than written to, so a page beyond the last simply displays the last.
v-model:perPagenumbernone
How many rows a page holds. Any value above zero turns the pagination on, so passing one down without binding it is enough to enable it.
v-model:selectedDataTableRowId[][]
The selected rows, as the identities rowKey gives them, never the row objects themselves. Nothing is selected to begin with, and a selection survives a change of page: the header checkbox covers the visible page alone, which is why it can be indeterminate. The footer counts this list as it stands, identities of rows no longer shown included.
v-model:searchstring''
What is typed in the search field, empty to begin with. Only the declared columns are searched, accent- and case-insensitively; in server mode nothing is filtered here and the term is reported instead.

Events

EventType
update:params[params: DataTableParams]
What the table is being asked for, in server mode: the search, the sort, the page and the page size. It fires on every change and never on mount, and an equal value handed down again asks for nothing.

Slots

SlotType
title{}
The left side of the toolbar, replacing the title prop.
loading{}
What the table shows while its rows are loading, replacing the spinner and its text.
emptyDataTableEmptySlotProps
What the table shows when there is no row to show, replacing emptyText. It receives the search that produced the empty result, empty when nothing was searched for.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export interface BuiltinIcon {
  name: string
  paths: readonly [string] | readonly [string, string]
}
export interface DataTableColumn {
  key: string
  label: string
  sortable?: boolean
  align?: DataTableColumnAlign
}
export type DataTableColumnAlign = 'start' | 'center' | 'end'
export interface DataTableEmptySlotProps {
  search: string
}
export interface DataTableParams {
  page: number
  perPage: number | null
  sortKey: string | null
  sortDirection: DataTableSortDirection | null
  search: string
}
export interface DataTableRange {
  start: number
  end: number
  total: number
}
export type DataTableRowId = string | number
export interface DataTableSort {
  key: string
  direction: DataTableSortDirection
}
export type DataTableSortDirection = 'asc' | 'desc'
export type IconRender =
  | { path: string; viewBox?: string }
  | { component: Component; props?: Record<string, unknown> }
  | { src: string }
  | { text: string; class?: string }
  | { class: string }
export type IconSource = string | BuiltinIcon | IconRender

CSS variables

TokenValue
--vectis-control-size-table-search16rem