Skip to content

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 TradersSep 21, 20261,280.00EURPaid
Blue Yonder AirlinesSep 22, 2026460.50EURDue
Contoso Pharmaceuticals and LaboratoriesSep 22, 20262,150.00EUROverdue
FabrikamSep 23, 202690.00EURPaid
Tailspin ToysSep 24, 2026730.25EURDue
<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 · typeDefaultDescription
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= nullnullThe 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= falsefalseRows are on their way: skeleton rows on the first load, the rows in view dimmed on a refresh.
errorstring | null= nullnullWhy 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= falsefalseThe 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= 55Skeleton 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= falsefalseA 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= falsefalseRows 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 · typeDefaultDescription
value*DateValue | nullAn 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 · typeDefaultDescription
value*string | number | nullThe amount in major units: a number or the decimal string an API sends. Empty or unreadable prints a dash.
currencystringISO 4217 code for this cell; unset, the app's currency (createGrani({ currency })).
unitstringWhat the amount is per, kg or pc; shown after the currency as "EUR / kg".
tonesuccess | danger | infoMeaning 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 needTake
A list screenData table, with its toolbar, pages and selection strip
Rows of settings or factsList item rows in a List
A list of names that grows from its last rowList field
The changes of one eventLog's own table