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.
| Selection | Status | Actions | ||||
|---|---|---|---|---|---|---|
| Northwind Traders | Sep 24, 202609:15 | 90.00EUR | Paid | |||
| Northwind Traders | Sep 24, 202609:15 | 1,370.00EUR | Due | |||
| Blue Yonder Airlines | Sep 23, 202610:15 | 227.00EUR | Due | |||
| Blue Yonder Airlines | Sep 23, 202610:15 | 1,507.00EUR | Overdue | |||
| Contoso | Sep 22, 202611:15 | 364.00EUR | Overdue | |||
| Contoso | Sep 22, 202611:15 | 1,644.00EUR | Paid | |||
| Fabrikam | Sep 21, 202612:15 | 501.00EUR | Paid | |||
| Fabrikam | Sep 21, 202612:15 | 1,781.00EUR | Due | |||
| Tailspin Toys | Sep 20, 202613:15 | 638.00EUR | Due | |||
| Tailspin Toys | Sep 20, 202613:15 | 1,918.00EUR | Overdue | |||
| 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 · type | Default | Description |
|---|---|---|
| Value | ||
| search*string | The search text (v-model:search); the field is always there. | |
| pagenumber= 1 | 1 | Current 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= null | null | The 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= false | false | Rows are on their way. |
| errorstring | null= null | null | Why the rows could not be loaded, in words; shown with a retry. |
| filteredboolean= false | false | A 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 | ||
| totalnumber | Records 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= 25 | 25 | Records 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= false | false | A checkbox on every row; the selection strip replaces the toolbar while rows are selected. |
| isRowSelectable((row: T) => boolean) | Which rows can be selected. | |
| searchPlaceholderstring | What 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= false | false | Rows answer a click with row-click. The keyboard reaches a record through a control in #row-actions. |
| Look | ||
| sizexs | sm | md | Step 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 need | Take |
|---|---|
| A table with no toolbar and no pages | A bare Table |
| A list of names that grows from its last row | List field |
| Rows of settings or actions | List item rows in a List |