Skip to content

Data table

Find and work with records in one place: search and filters above a table with selection and pages, and clear loading and error states.

SelectionStatusActions
Northwind TradersSep 24, 202609:1590.00EURPaid
Northwind TradersSep 24, 202609:151,370.00EURDue
Blue Yonder AirlinesSep 23, 202610:15227.00EURDue
Blue Yonder AirlinesSep 23, 202610:151,507.00EUROverdue
ContosoSep 22, 202611:15364.00EUROverdue
ContosoSep 22, 202611:151,644.00EURPaid
FabrikamSep 21, 202612:15501.00EURPaid
FabrikamSep 21, 202612:151,781.00EURDue
Tailspin ToysSep 20, 202613:15638.00EURDue
Tailspin ToysSep 20, 202613:151,918.00EUROverdue
59,982.00EUR
1–10 of 57
<script setup lang="ts">import { computed, ref, watch } from 'vue'import { Pencil, Plus, Trash2 } from '@lucide/vue'import type { DataTableColumn, TableSort } from 'grani'import { UiDataTable, UiFilter, UiButton, UiIconButton, UiMoneyCell, UiCrudDrawer, UiCard, UiField, UiInput, UiDatePicker, UiRadioGroup, UiRadio, UiToaster, useToast, useTableColumns } from 'grani' interface Invoice {  id: string  client: string  issued: string  amount: number  status: 'paid' | 'due' | 'overdue'  rating: number | null} const CLIENTS = [  'Northwind Traders',  'Blue Yonder Airlines',  'Contoso',  'Fabrikam',  'Tailspin Toys',] const STATUS: Invoice['status'][] = ['paid', 'due', 'overdue'] const all = ref<Invoice[]>(  Array.from({ length: 57 }, (_, i) => {    const status = STATUS[i % 3]!    return {      id: `INV-${1100 - i}`,      client: CLIENTS[i % CLIENTS.length]!,      issued: new Date(2026, 8, 24 - (i % 40), 9 + (i % 8), 15).toISOString(),      amount: 90 + ((i * 137) % 2100),      status,      rating: i % 4 === 0 ? null : 1 + ((i * 3) % 5),    }  }),) const TONE = { paid: 'success', due: 'neutral', overdue: 'danger' } as const const LABEL = { paid: 'Paid', due: 'Due', overdue: 'Overdue' } as const const columns: DataTableColumn<Invoice>[] = [  { key: 'id', label: 'Invoice', type: 'copy', sortable: true },  { key: 'client', label: 'Client', truncate: true, sortable: true },  {    key: 'issued',    label: 'Issued',    type: 'date',    sortable: true,    defaultSortDir: 'desc',  },  {    key: 'amount',    label: 'Amount',    type: 'money',    currency: 'EUR',    align: 'end',    sortable: true,    defaultSortDir: 'desc',    tone: (r) => (r.status === 'overdue' ? 'danger' : undefined),  },  {    key: 'status',    label: 'Status',    type: 'status',    format: (v) => LABEL[v as Invoice['status']],    tone: (r) => TONE[r.status],  },  { key: 'rating', label: 'Rating', type: 'progress', max: 5, step: 1 },] // The invoice number says what a row is, so it cannot be hidden.// Rating is off until asked for; a new kind, so a choice stored before the column existed starts over.const cols = useTableColumns('demo-invoices-list', columns, {  required: ['id'],  hiddenByDefault: ['rating'],}) const statuses = STATUS.map((s) => ({ value: s, label: LABEL[s] })) // The demo stands in for the engine: it filters, sorts and pages the fixture itself.const search = ref('') const status = ref<string[]>([]) const sort = ref<TableSort>({ key: 'issued', dir: 'desc' }) const page = ref(1) const PAGE_SIZE = 10 const selection = ref(new Set<string | number>()) const last = ref('') const filtered = computed(() =>  all.value.filter(    (r) =>      (!status.value.length || status.value.includes(r.status)) &&      (!search.value || r.client.toLowerCase().includes(search.value.toLowerCase())),  ),) const sorted = computed(() => {  const s = sort.value  if (!s) return filtered.value  const dir = s.dir === 'asc' ? 1 : -1  return [...filtered.value].sort((a, b) => {    const av = a[s.key as keyof Invoice] ?? ''    const bv = b[s.key as keyof Invoice] ?? ''    return (av < bv ? -1 : av > bv ? 1 : 0) * dir  })}) const pageRows = computed(() =>  sorted.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE),) const narrowed = computed(() => Boolean(search.value || status.value.length)) // A filter of the page's own asks for page one; the block does it for its search and sort.watch(status, () => (page.value = 1)) // New invoice and edit share one drawer: the draft is a copy, Save puts it back.const drawerOpen = ref(false) const saving = ref(false) const editing = ref<Invoice | null>(null) const blank = () => ({  client: '',  amount: null as string | null,  issued: '2026-09-29',  status: 'due' as Invoice['status'],}) const draft = ref(blank()) let start = JSON.stringify(draft.value) const dirty = computed(() => JSON.stringify(draft.value) !== start) // Save stays pressable: a value it refuses is named on its field as Save is pressed, and the// drawer puts the focus on the first one.const errors = ref({ client: '', amount: '' }) const toast = useToast() function openRecord(row: Invoice | null) {  editing.value = row  draft.value = row    ? {        client: row.client,        amount: String(row.amount),        issued: row.issued.slice(0, 10),        status: row.status,      }    : blank()  start = JSON.stringify(draft.value)  errors.value = { client: '', amount: '' }  drawerOpen.value = true} function save() {  errors.value = {    client: draft.value.client.trim() ? '' : 'Enter a client',    amount: Number(draft.value.amount) > 0 ? '' : 'Enter an amount above 0',  }  if (errors.value.client || errors.value.amount) return  saving.value = true  setTimeout(() => {    const d = draft.value    const fields = {      client: d.client.trim(),      amount: Number(d.amount),      issued: new Date(`${d.issued}T09:00:00`).toISOString(),      status: d.status,      rating: null,    }    if (editing.value) {      const id = editing.value.id      all.value = all.value.map((r) => (r.id === id ? { ...r, ...fields } : r))      last.value = `${id} saved`    } else {      const next = Math.max(...all.value.map((r) => Number(r.id.slice(4)))) + 1      all.value = [{ id: `INV-${next}`, ...fields }, ...all.value]      page.value = 1      last.value = `INV-${next} created`    }    saving.value = false    drawerOpen.value = false    toast.success(last.value)  }, 500)} // A delete answers with a toast and gives the rows back on Undo; the block moves the page if// the list got shorter under it.const deleteSelected = () => {  const gone = all.value.filter((r) => selection.value.has(r.id))  const before = all.value  all.value = all.value.filter((r) => !selection.value.has(r.id))  selection.value = new Set()  toast.success(gone.length === 1 ? `${gone[0]!.id} deleted` : `${gone.length} invoices deleted`, {    action: { label: 'Undo', onClick: () => (all.value = before) },  })}</script> <template>  <UiDataTable    v-model:search="search"    v-model:sort="sort"    v-model:page="page"    v-model:selection="selection"    class="w-full"    search-placeholder="Search by client"    :columns="cols"    :items="pageRows"    :total="sorted.length"    :page-size="PAGE_SIZE"    :filtered="narrowed"    selectable    :row-label="(row) => row.id"    clickable    @row-click="openRecord($event)"    @reset-filters="status = []"  >    <template #filters="{ size }">      <UiFilter v-model="status" label="Status" :items="statuses" :size="size" />    </template>    <template #actions>      <UiButton @click="openRecord(null)"><Plus />New invoice</UiButton>    </template>    <template #bulk="{ size }">      <UiButton variant="secondary" tone="danger" :size="size" @click="deleteSelected"        ><Trash2 />Delete</UiButton      >    </template>    <template #row-actions="{ row }">      <UiIconButton variant="ghost" :icon="Pencil" ariaLabel="Edit" @click="openRecord(row)" />    </template>    <template #footer.amount>      <UiMoneyCell :value="sorted.reduce((s, r) => s + r.amount, 0)" currency="EUR" />    </template>  </UiDataTable>  <UiCrudDrawer    v-model:open="drawerOpen"    :title="editing ? editing.id : 'New invoice'"    :description="editing ? editing.client : undefined"    :submitting="saving"    :persistent="dirty"    @submit="save"  >    <UiCard>      <div class="flex flex-col gap-4">        <UiField label="Client" :error="errors.client"          ><UiInput v-model="draft.client"        /></UiField>        <UiField label="Amount" :error="errors.amount"          ><UiInput v-model="draft.amount" numeric        /></UiField>        <UiField label="Issued"><UiDatePicker v-model="draft.issued" /></UiField>        <UiField label="Status" group>          <UiRadioGroup v-model="draft.status" variant="segmented">            <UiRadio v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</UiRadio>          </UiRadioGroup>        </UiField>      </div>    </UiCard>  </UiCrudDrawer>  <!-- Once, at the app's root: leave out what the app has there already. -->  <UiToaster /></template>

Also called data grid, list page, crud table, admin table.

Usage

<UiDataTable  :columns="columns"  :items="items"  v-model:search="search"/>

With GraniResolver in the Vite config, a template needs no import: set it up once.

API

Name · typeDefaultDescription
Value
search*stringThe search text (v-model:search); the field is always there.
pagenumber= 11Current page, from 1 (v-model:page). A new search or sort asks for 1; a page past the last asks for the last once the list is not loading.
sortTableSort= nullnullThe current sort (v-model:sort); the parent orders the rows.
selectionSet<string | number>= new Set<string | number>()new Set<string | number>()Keys of the selected rows (v-model:selection).
Data
columns*DataTableColumn<T>[] | TableColumns<T>The columns in order, each with how its values are drawn: fixed; or the state from useTableColumns over them, and then the table draws its visible and the toolbar gets the columns button.
items*T[]Rows of the current page.
State
loadingboolean= falsefalseRows are on their way.
errorstring | null= nullnullWhy the rows could not be loaded, in words; shown with a retry.
filteredboolean= falsefalseA filter of the page's own is on: an empty page says so and offers "Reset filters", which sends reset-filters. A UiFilter in #filters and the block's own search count by themselves.
Behavior
totalnumberRecords in the whole list; gives the count and the pages. Unset, there is no footer. Leave it unset until the first answer (a 0 would move a linked page back to one) and keep the last value while a page loads.
pageSizenumber= 2525Records per page; the count and the pages are derived from it.
rowLabel((row: T) => string)Names a row for its checkbox: "Select Anna" instead of "Select row".
rowKeyRowKey<T>What tells rows apart; id when unset.
selectableboolean= falsefalseA checkbox on every row; the selection strip replaces the toolbar while rows are selected.
isRowSelectable((row: T) => boolean)Which rows can be selected.
searchPlaceholderstringWhat the search looks in: its placeholder and its name. Unset, "Search" and the table's aria-label ("Search Invoices"), so two lists on one page keep their searches apart.
clickableboolean= falsefalseRows answer a click with row-click. The keyboard reaches a record through a control in #row-actions.
Look
sizexs | sm | mdStep of the toolbar row: xs for a dense row, sm (36 px), md for a comfortable one; unset, the app's size (createGrani({ size })). The slots get it as { size }.

Attributes: class and style stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it.

When to use another component

When you needTake
A table with no toolbar and no pagesA bare Table
A list of names that grows from its last rowList field
Rows of settings or actionsList item rows in a List