DataTable
A typed table that sorts, paginates, selects and exports, and turns into cards on a phone.
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import type { ColumnDef, PaginationConfig } from '@shared/components/common/DataTable/types'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import DataTableFilter from '@shared/components/common/DataTable/DataTableFilter.vue'
import { computed, h, ref } from 'vue'
interface Order extends Record<string, unknown> {
id: number
customer: string
status: 'paid' | 'pending' | 'refunded'
total: number
placedAt: string
}
const ORDERS: Order[] = [
{ id: 1041, customer: 'Amina Haddad', status: 'paid', total: 249, placedAt: '2026-03-01' },
{ id: 1042, customer: 'Luca Rossi', status: 'pending', total: 139, placedAt: '2026-03-02' },
{ id: 1043, customer: 'Mei Tanaka', status: 'paid', total: 512, placedAt: '2026-03-02' },
{ id: 1044, customer: 'Pedro Alves', status: 'refunded', total: 98, placedAt: '2026-03-03' },
{ id: 1045, customer: 'Sara Lindqvist', status: 'paid', total: 1420, placedAt: '2026-03-04' },
{ id: 1046, customer: 'Omar Farouk', status: 'pending', total: 76, placedAt: '2026-03-05' },
{ id: 1047, customer: 'Nadia Petrova', status: 'paid', total: 305, placedAt: '2026-03-05' },
{ id: 1048, customer: 'Tom Becker', status: 'refunded', total: 219, placedAt: '2026-03-06' },
{ id: 1049, customer: 'Ines Duarte', status: 'paid', total: 640, placedAt: '2026-03-07' },
{ id: 1050, customer: 'Kwame Mensah', status: 'pending', total: 187, placedAt: '2026-03-08' },
{ id: 1051, customer: 'Ayse Yilmaz', status: 'paid', total: 432, placedAt: '2026-03-09' },
{ id: 1052, customer: 'Diego Morales', status: 'paid', total: 264, placedAt: '2026-03-10' },
]
const STATUS_TONE: Record<Order['status'], string> = {
paid: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400',
pending: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
refunded: 'bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-400',
}
const STATUS_LABEL: Record<Order['status'], string> = {
paid: 'Paid',
pending: 'Pending',
refunded: 'Refunded',
}
const columns: ColumnDef<Order>[] = [
{ key: 'id', label: 'Order', sortable: true, width: '90px' },
{ key: 'customer', label: 'Customer', sortable: true },
{
key: 'status',
label: 'Status',
sortable: true,
render: (value) => {
const status = value as Order['status']
return h(
'span',
{ class: `inline-block rounded-full px-2 py-0.5 sa-text-xs font-medium ${STATUS_TONE[status]}` },
STATUS_LABEL[status],
)
},
},
{ key: 'total', label: 'Total', sortable: true, align: 'right', render: value => `$${Number(value).toLocaleString('en-US')}` },
{ key: 'placedAt', label: 'Placed', sortable: true, hideOn: 'sm' },
]
const filterFields: FieldDefinition[] = [
{ key: 'customer', type: 'text', label: 'Customer', placeholder: 'Search by name' },
{
key: 'status',
type: 'select',
label: 'Status',
options: [
{ label: 'Paid', value: 'paid' },
{ label: 'Pending', value: 'pending' },
{ label: 'Refunded', value: 'refunded' },
],
},
]
const filters = ref<Record<string, unknown>>({})
const selection = ref<unknown[]>([])
const pagination = ref<PaginationConfig>({ page: 1, perPage: 5, total: ORDERS.length })
const matching = computed(() => ORDERS.filter((order) => {
const name = String(filters.value.customer ?? '').toLowerCase()
const status = filters.value.status
if (name && !order.customer.toLowerCase().includes(name))
return false
if (status && order.status !== status)
return false
return true
}))
const rows = computed(() => {
const start = (pagination.value.page - 1) * pagination.value.perPage
return matching.value.slice(start, start + pagination.value.perPage)
})
function applyFilters(): void {
pagination.value = { ...pagination.value, page: 1, total: matching.value.length }
}
</script>
<template>
<div class="flex flex-col gap-4">
<DataTableFilter
v-model="filters"
mode="inline"
:fields="filterFields"
@apply="applyFilters"
@reset="applyFilters"
/>
<DataTable
:columns="columns"
:data="rows"
:pagination="pagination"
selectable
:toolbar="false"
row-key="id"
@update:pagination="pagination = $event"
@update:selection="selection = $event"
/>
<p class="text-sm text-muted-foreground">
{{ selection.length }} selected of {{ matching.length }} matching
</p>
</div>
</template>Click a header to sort, type a name to filter, tick rows, and page through. Narrow the window past 768px and the same table becomes cards.
The shape of it
<template>
<DataTable
:columns="columns"
:data="rows"
:pagination="pagination"
selectable
row-key="id"
@update:pagination="pagination = $event"
@update:selection="selection = $event"
/>
</template>Pagination is controlled. The component never slices data: it shows exactly the rows you hand it and tells you when the reader asks for another page. That is the same contract for a client-side list and a server-paginated one, so moving from one to the other changes your fetch and nothing else.
Columns
import type { ColumnDef } from '@shared/components/common/DataTable/types'
const columns: ColumnDef<Order>[] = [
{ key: 'id', label: 'Order', sortable: true, width: '90px' },
{ key: 'customer', label: 'Customer', sortable: true },
{ key: 'total', label: 'Total', sortable: true, align: 'right' },
{ key: 'placedAt', label: 'Placed', hideOn: 'sm' },
]| Key | Type | Description |
|---|---|---|
key | keyof T & string | The property to read. |
label | string | Header text. A translation key or a literal. |
sortable | boolean | Adds the sort control to this header. |
filterable | boolean | Marks the column as filterable for a filter bar. |
width | string | Any CSS width, such as '120px' or '20%'. |
align | 'left' | 'center' | 'right' | Cell alignment. |
render | (value, row) => string | VNode | Custom cell output, with no wrapper element. |
hideOn | 'sm' | 'md' | 'lg' | Hide this column below that width. |
editable | boolean | Double-click swaps the cell for an input. Table mode only, and a render always wins. |
Columns can come from a schema instead of being written by hand — see Schema.
The full API
Every prop, emit and slot, with its default, lives on the component page: Components → DataTable. The rest of this page is how the pieces fit together.
Sorting
Click a header to cycle ascending, descending, off. Sorting is local by default. Set serverSort and the cycle becomes ascending then descending, and the component only emits update:sort — your fetch does the work.
Selection
update:selection carries row keys, not row objects. That matches what a bulk action needs and stays correct on a server-paginated table, where a row selected on page one is no longer in data by the time the reader acts on page three.
const bulkActions: BulkActionDef[] = [
{ label: 'Delete', icon: Trash2, variant: 'destructive', handler: keys => removeMany(keys) },
]Row actions
const actions: ActionDef<Order>[] = [
{ label: 'View', icon: Eye, handler: row => router.push(`/orders/${row.id}`) },
{ label: 'Refund', icon: Undo2, handler: refund, visibleIf: row => row.status === 'paid' },
{ label: 'Delete', icon: Trash2, variant: 'destructive', handler: remove },
]Two or fewer visible actions render as inline buttons; three or more collapse into an overflow menu. visibleIf is evaluated per row, so a table with four actions can still show inline buttons on a row where only one applies. actionsMenu forces the menu either way.
Filtering
The filter bar is its own component, so a page can place it where it belongs. It takes the same FieldDefinition[] the form engine uses.
<template>
<DataTableFilter
v-model="filters"
mode="collapsible"
:fields="filterFields"
@apply="refetch"
@reset="refetch"
/>
</template>mode is inline, collapsible, drawer or modal. Inline filters as the reader types, debounced by 300ms, and says so on the bar; the other three wait for Apply. It emits update:modelValue as values change, plus apply and reset, and counts the active filters for its badge, where active means a value that is not empty, null or an empty list.
Applying the filter to the rows is the page's job — the bar collects the values and says when.
The toolbar
Export and print are on by default; import is opt-in.
- Export writes CSV, Excel or PDF of the current
dataandcolumns. The libraries behind Excel and PDF are imported on demand, so a page that never exports never downloads them. - Print opens the browser's print dialog on a print-only rendering of the table.
- Import needs
importFields. It offers a downloadable template built from those fields, parses the uploaded file, coerces each column to its declared type, and emitsimportwith the rows. Nothing is saved until you save it.
Column visibility
DataTableColumnToggle is deliberately not rendered by the table: a page decides where it belongs.
<template>
<DataTableColumnToggle v-model="visibleColumns" :columns="columns" />
<DataTable :columns="columns" :data="rows" :visible-columns="visibleColumns" />
</template>It remembers the reader's choice, keyed by route name unless you pass storageKey.
On a phone
Below 768px each row becomes a card, which is why a twelve-column table stays readable without a horizontal scroll. mobileCards: false opts out; displayMode="card" forces cards at every width.
Right to left
In a right-to-left layout the columns reverse with the page, the actions column stays at the inline end, and the expand arrow flips. Nothing needs configuring: the component uses logical alignment throughout.
SchemaTable
When a table is nothing but the schema, skip the column definitions:
<script setup lang="ts">
import SchemaTable from '@core/schema/SchemaTable.vue'
import { productFormSchema } from '../types/product.schemas'
</script>
<template>
<SchemaTable :schema="productFormSchema" :data="products" />
</template>Two props, schema and data. It renders every field without hideInTable, formats dates in the app's language, and re-renders the cells when the language changes. When you need sorting, selection or actions, use DataTable with schemaToColumns.
Where to go next
- Schema — where the columns can come from.
- Add a module — the CLI writes a list page built on this.