Table
Compare records in columns, sort and select rows and act on a row, with clear states for loading, empty results and errors.
| Status | ||||
|---|---|---|---|---|
| Northwind Traders | Sep 21, 2026 | 1,280.00EUR | Paid | |
| Blue Yonder Airlines | Sep 22, 2026 | 460.50EUR | Due | |
| Contoso Pharmaceuticals and Laboratories | Sep 22, 2026 | 2,150.00EUR | Overdue | |
| Fabrikam | Sep 23, 2026 | 90.00EUR | Paid | |
| Tailspin Toys | Sep 24, 2026 | 730.25EUR | Due |
<script setup lang="ts">import type { TableColumn } from 'grani'import { UiCard, UiTable, UiCopyButton, UiDateCell, UiMoneyCell, UiBadge } from 'grani' interface Invoice { id: number number: string client: string issued: string amount: number status: 'paid' | 'due' | 'overdue'} const ROWS: Invoice[] = [ { id: 1, number: 'INV-1041', client: 'Northwind Traders', issued: '2026-09-21', amount: 1280, status: 'paid', }, { id: 2, number: 'INV-1042', client: 'Blue Yonder Airlines', issued: '2026-09-22', amount: 460.5, status: 'due', }, { id: 3, number: 'INV-1043', client: 'Contoso Pharmaceuticals and Laboratories', issued: '2026-09-22', amount: 2150, status: 'overdue', }, { id: 4, number: 'INV-1044', client: 'Fabrikam', issued: '2026-09-23', amount: 90, status: 'paid', }, { id: 5, number: 'INV-1045', client: 'Tailspin Toys', issued: '2026-09-24', amount: 730.25, status: 'due', },] const columns: TableColumn<Invoice>[] = [ { key: 'number', label: 'Invoice', sortable: true }, { key: 'client', label: 'Client', sortable: true, truncate: true }, { key: 'issued', label: 'Issued', width: '130px', sortable: true, defaultSortDir: 'desc' }, { key: 'amount', label: 'Amount', width: '130px', align: 'end', sortable: true, defaultSortDir: 'desc', }, { key: 'status', label: 'Status' },] const TONE = { paid: 'success', due: 'neutral', overdue: 'danger' } as const const LABEL = { paid: 'Paid', due: 'Due', overdue: 'Overdue' } as const</script> <template> <UiCard :padded="false" class="w-full"> <UiTable :columns="columns" :items="ROWS" aria-label="Invoices"> <template #cell.number="{ value }"> <UiCopyButton size="xs" flush :value="String(value)" :label="String(value)" /> </template> <template #cell.issued="{ value }"> <UiDateCell :value="String(value)" /> </template> <template #cell.amount="{ value }"> <UiMoneyCell :value="Number(value)" currency="EUR" /> </template> <template #cell.status="{ value }"> <UiBadge :tone="TONE[value as Invoice['status']]" size="sm"> {{ LABEL[value as Invoice['status']] }} </UiBadge> </template> </UiTable> </UiCard></template>
Also called simple table, grid, rows and columns.
Usage
<UiTable :columns="columns" :items="items" /> With GraniResolver in the Vite config, a template needs no import: set it up once.
API
| Name · type | Default | Description |
|---|---|---|
| Value | ||
| selectionSet<string | number>= new Set<string | number>() | new Set<string | number>() | Keys of the selected rows (v-model:selection); the page checkbox adds or removes the page, Shift with a row's checkbox the rows from the last one marked. The parent drops the keys of rows it deletes. |
| sortTableSort= null | null | The current sort (v-model:sort); a header click cycles the column's first direction, the other one, then none. |
| Data | ||
| columns*TableColumn<T>[] | The columns in order; each names its #cell.<key> slot. A column is { key, label } and may add accessor(row) (the value when it is not row[key]), format(value, row) (the text), sortable with defaultSortDir (asc or desc, the first press), align: 'end' (numbers, digit under digit), width (one CSS value, 120px or calc(…)), truncate (one line, cut with a tooltip; cut columns with no width share the room left evenly). | |
| items*T[] | Rows of the current page. The table does not load, filter or sort them. | |
| State | ||
| loadingboolean= false | false | Rows are on their way: skeleton rows on the first load, the rows in view dimmed on a refresh. |
| errorstring | null= null | null | Why the rows could not be loaded, in words for the reader, shown with a retry. A failed refresh shows the same plate in place of the rows. |
| filteredboolean= false | false | The page is empty because of a filter or a search: the empty text says so. |
| Behavior | ||
| rowKeyRowKey<T> | What tells rows apart: a field name or a function; id when unset, so rows without an id need it. A row without a key cannot be selected, and is known by its object: a parent that rebuilds its rows on every render remounts them all. | |
| skeletonRowsnumber= 5 | 5 | Skeleton rows on the first load: the page size, so nothing moves when the rows arrive. |
| rowLabel((row: T) => string) | Names a row for its checkbox: "Select Anna" instead of "Select row". | |
| selectableboolean= false | false | A checkbox on every row and one for the page in the header. |
| isRowSelectable((row: T) => boolean) | Which rows can be selected; the others show a disabled checkbox and stay out of the page checkbox. | |
| clickableboolean= false | false | Rows answer a click with row-click and show a pointer cursor. A pointer shortcut: the keyboard path to a row is a control in it (#row-actions), the row itself takes no tab stop. |
Attributes: class and style stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it.
Date cell
| Name · type | Default | Description |
|---|---|---|
| value*DateValue | null | An ISO string with a time or a day alone, a Date, or a timestamp. A time with no zone is read in the app's zone. Empty or unreadable prints a dash. | |
| variantday | time= "day" | "day" | Which fact leads: time on top for a list within a day, day on top for a list across months. |
| contextweekday | relative= "relative" | "relative" | What the lower line answers: how long ago (relative), or which day of the week (weekday). |
Attributes are placed by the component itself: its description says where.
Money cell
| Name · type | Default | Description |
|---|---|---|
| value*string | number | null | The amount in major units: a number or the decimal string an API sends. Empty or unreadable prints a dash. | |
| currencystring | ISO 4217 code for this cell; unset, the app's currency (createGrani({ currency })). | |
| unitstring | What the amount is per, kg or pc; shown after the currency as "EUR / kg". | |
| tonesuccess | danger | info | Meaning of the amount: success, danger, info. Unset is the normal ink. |
Anything else lands on the root element: native attributes, aria-*, data-testid.
When to use another component
| When you need | Take |
|---|---|
| A list screen | Data table, with its toolbar, pages and selection strip |
| Rows of settings or facts | List item rows in a List |
| A list of names that grows from its last row | List field |
| The changes of one event | Log's own table |