Recipes
What to type. Every other page is a live table you can poke; this one is the code that gets you one. Ported from pnpm demo's Recipes view, which now links back here instead of being the only place they exist.
A table, from nothing
Three pieces: state (what to show), a source (where rows come from), and the preset that renders them. type is what makes filters and sorting behave — it picks the comparator and decides which operators the filter panel offers.
| Person 1 | Engineering | $50,000 | 2015-01-01 | |
| Person 2 | Design | $57,919 | 2016-02-02 | |
| Person 3 | Sales | $65,838 | 2017-03-03 | |
| Person 4 | Support | $73,757 | 2018-04-04 | |
| Person 5 | Engineering | $81,676 | 2019-05-05 | |
| Person 6 | Design | $89,595 | 2020-06-06 | |
| Person 7 | Sales | $97,514 | 2021-07-07 | |
| Person 8 | Support | $105,433 | 2022-08-08 | |
| Person 9 | Engineering | $113,352 | 2023-09-09 | |
| Person 10 | Design | $121,271 | 2015-10-10 |
<script setup lang="ts">
import { shallowRef } from 'vue'
import { DataTable, useLocalDataSource, useTableState, type ColumnDef } from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
},
{ id: 'hiredAt', header: 'Hired', type: 'date' },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
salary: 50_000 + ((i * 7919) % 90_000),
hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
}))
}
// shallowRef, not ref: a plain ref proxies every row object, and every cell
// read during a filter or sort then goes through a Proxy trap.
const rows = shallowRef<Person[]>(makePeople(300))
const state = useTableState({ pageSize: 10 })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<DataTable :columns="columns" :source="source" :state="state" selectable>
<template #cell:name="{ row }">
<a :href="`/people/${row.id}`">{{ row.name }}</a>
</template>
</DataTable>
</template>Rows from a server
Swap the source and nothing above it changes — both satisfy the same DataSource contract. A server source adds debouncing on filter/search/sort (never on paging), abort-and-race safety so a slow earlier response cannot overwrite a fast later one, and keepPreviousData so the table does not blank out between pages.
<script setup lang="ts">
import {
DataTable,
filterRows,
groupedSort,
sortRows,
useServerDataSource,
useTableState,
type ColumnDef,
type FetchParams,
type FetchResult,
} from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
},
{ id: 'hiredAt', header: 'Hired', type: 'date' },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
salary: 50_000 + ((i * 7919) % 90_000),
hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
}))
}
/**
* A stand-in server: what `GET /people?…` would do, done with the library's
* own pure functions over an array it holds. Replace the body with a `fetch`
* — the contract is the same `query` in and `{ rows, total }` out.
*/
const ALL = makePeople(2000)
async function fetchPeople({ query, signal }: FetchParams): Promise<FetchResult<Person>> {
await new Promise((resolve) => setTimeout(resolve, 300))
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
const matched = filterRows(ALL, columns, query)
const ordered = sortRows(matched, groupedSort(query.sort, query.groupBy), columns)
const start = (query.page - 1) * query.pageSize
return { rows: ordered.slice(start, start + query.pageSize), total: matched.length }
}
const state = useTableState({ pageSize: 10 })
// Swap `useLocalDataSource` for this and nothing above it changes: the
// columns, the state and the component never learn which one they got.
const source = useServerDataSource(fetchPeople, state.query, { debounceMs: 300 })
</script>
<template>
<DataTable :columns="columns" :source="source" :state="state" />
</template>A shareable table
QueryState is deliberately JSON-safe, so the whole view — sort, filters, page, search — fits in a URL or a store. Hand useTableState a ref through its state option and that ref becomes the single source of truth: the table writes straight through to it, and writing to it from outside (a route change, a store mutation) moves the table.
| Person 1 | Engineering | $50,000 | 2015-01-01 |
| Person 2 | Design | $57,919 | 2016-02-02 |
| Person 3 | Sales | $65,838 | 2017-03-03 |
| Person 4 | Support | $73,757 | 2018-04-04 |
| Person 5 | Engineering | $81,676 | 2019-05-05 |
| Person 6 | Design | $89,595 | 2020-06-06 |
| Person 7 | Sales | $97,514 | 2021-07-07 |
| Person 8 | Support | $105,433 | 2022-08-08 |
| Person 9 | Engineering | $113,352 | 2023-09-09 |
| Person 10 | Design | $121,271 | 2015-10-10 |
{"sort":[],"filters":{},"groupBy":[],"page":1,"pageSize":10,"globalSearch":""}
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import {
DataTable,
createQueryState,
useLocalDataSource,
useTableState,
type ColumnDef,
type QueryState,
} from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
},
{ id: 'hiredAt', header: 'Hired', type: 'date' },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
salary: 50_000 + ((i * 7919) % 90_000),
hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
}))
}
const rows = shallowRef<Person[]>(makePeople(200))
// A stand-in for a router's `route.query` or a store field — a real app
// would mirror this ref to and from the URL. `useTableState` writes straight
// through to whatever ref it is handed, so the table owns nothing of its own.
const hoisted = ref<QueryState>(createQueryState({ pageSize: 10 }))
const state = useTableState({ state: hoisted })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<DataTable :columns="columns" :source="source" :state="state" />
<p><code>{{ JSON.stringify(hoisted) }}</code></p>
</template>Banded rows, with totals
Declare an aggregate on a column and every band gets that figure, plus the footer if you ask for one with show-footer. groupMode decides who does the work: 'client' (the default) bands the rows already loaded and never refetches; 'server' puts the grouping in the query instead, so bands stay whole across pages and counts describe the entire group rather than one page of it.
| $552,654 | 2015-10-10 | ||
| Person 2 | Design | $57,919 | 2016-02-02 |
| Person 6 | Design | $89,595 | 2020-06-06 |
| Person 10 | Design | $121,271 | 2015-10-10 |
| Person 14 | Design | $62,947 | 2019-02-14 |
| Person 18 | Design | $94,623 | 2023-06-18 |
| Person 22 | Design | $126,299 | 2018-10-22 |
| $565,196 | 2015-01-01 | ||
| Person 1 | Engineering | $50,000 | 2015-01-01 |
| Person 5 | Engineering | $81,676 | 2019-05-05 |
| Person 9 | Engineering | $113,352 | 2023-09-09 |
| Person 13 | Engineering | $55,028 | 2018-01-13 |
| Person 17 | Engineering | $86,704 | 2022-05-17 |
| Person 21 | Engineering | $118,380 | 2017-09-21 |
| Person 25 | Engineering | $60,056 | 2021-01-25 |
| $600,168 | 2015-07-19 | ||
| Person 3 | Sales | $65,838 | 2017-03-03 |
| Person 7 | Sales | $97,514 | 2021-07-07 |
| Person 11 | Sales | $129,190 | 2016-11-11 |
| Person 15 | Sales | $70,866 | 2020-03-15 |
| Person 19 | Sales | $102,542 | 2015-07-19 |
| Person 23 | Sales | $134,218 | 2019-11-23 |
| $557,682 | 2016-08-20 | ||
| Person 4 | Support | $73,757 | 2018-04-04 |
| Person 8 | Support | $105,433 | 2022-08-08 |
| Person 12 | Support | $137,109 | 2017-12-12 |
| Person 16 | Support | $78,785 | 2021-04-16 |
| Person 20 | Support | $110,461 | 2016-08-20 |
| Person 24 | Support | $52,137 | 2020-12-24 |
| Total | $2,275,700 | 2015-01-01 | |
<script setup lang="ts">
import { shallowRef } from 'vue'
import { DataTable, useLocalDataSource, useTableState, type ColumnDef } from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
// `aggregate` is what fills in both the band totals and the footer.
aggregate: 'sum',
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
// `format` wants a row and a sum has none, so a total is dressed separately.
aggregateFormat: (result) => (result.value == null ? '—' : `$${Number(result.value).toLocaleString()}`),
},
{ id: 'hiredAt', header: 'Hired', type: 'date', aggregate: 'min' },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
salary: 50_000 + ((i * 7919) % 90_000),
hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
}))
}
const rows = shallowRef<Person[]>(makePeople(500))
const state = useTableState({ pageSize: 25, initialGroupBy: ['department'] })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<DataTable :columns="columns" :source="source" :state="state" show-footer />
</template>Retheming without touching a component
The preset's stylesheet hangs entirely off CSS variables. One thing to get right: a palette is a set. Overriding a light header colour while --vtc-text stays on its dark-mode value gives you white-on-white — see Styling for the whole list and how cell backgrounds stack.
| Person 1 | Engineering | $50,000 | 2015-01-01 |
| Person 2 | Design | $57,919 | 2016-02-02 |
| Person 3 | Sales | $65,838 | 2017-03-03 |
| Person 4 | Support | $73,757 | 2018-04-04 |
| Person 5 | Engineering | $81,676 | 2019-05-05 |
| Person 6 | Design | $89,595 | 2020-06-06 |
| Person 7 | Sales | $97,514 | 2021-07-07 |
| Person 8 | Support | $105,433 | 2022-08-08 |
| Person 9 | Engineering | $113,352 | 2023-09-09 |
| Person 10 | Design | $121,271 | 2015-10-10 |
<script setup lang="ts">
import { shallowRef } from 'vue'
import { DataTable, useLocalDataSource, useTableState, type ColumnDef } from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
},
{ id: 'hiredAt', header: 'Hired', type: 'date' },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
salary: 50_000 + ((i * 7919) % 90_000),
hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
}))
}
const rows = shallowRef<Person[]>(makePeople(60))
const state = useTableState({ pageSize: 10 })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<!-- Every override lives on the wrapper. The stylesheet hangs entirely off
custom properties, so no component needs to know a theme exists. A
palette is a set: override the header colour and the text colour
together, or a dark-mode value leaks through the light one. -->
<div
class="my-table"
style="
--vtc-accent: #7c3aed;
--vtc-bg: #ffffff;
--vtc-header-bg: #faf5ff;
--vtc-text: #1f2937;
--vtc-text-muted: #6b7280;
--vtc-border: #e5e7eb;
--vtc-row-height: 40px;
--vtc-radius: 8px;
/* Row striping is two variables; equal values mean no stripes. */
--vtc-row-odd-bg: #ffffff;
--vtc-row-even-bg: #fafafa;
"
>
<DataTable :columns="columns" :source="source" :state="state" />
</div>
</template>When the preset does not fit
TableRoot renders nothing of its own — the slot receives everything and decides the markup entirely. The preset is just one caller of it. Drop to the primitives and the same state, sorting, filtering and selection drive whatever you build, table or not — see Composing your own.
<script setup lang="ts">
// No preset, no stylesheet import — this file's own scoped CSS is the only
// styling anywhere in it, which is the whole "headless buys you zero CSS"
// contract.
import { shallowRef } from 'vue'
import {
ColumnFilterPopover,
SortTrigger,
TablePagination,
TableRoot,
useLocalDataSource,
useTableState,
type ColumnDef,
} from '@brillliand/vue-table-chad'
type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{ id: 'salary', header: 'Salary', type: 'number' },
{ id: 'hiredAt', header: 'Hired', type: 'date' },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
salary: 50_000 + ((i * 7919) % 90_000),
hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
}))
}
const rows = shallowRef<Person[]>(makePeople(120))
const state = useTableState({ pageSize: 10 })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<TableRoot v-slot="{ rows: pageRows, total }" :columns="columns" :source="source" :state="state">
<header class="toolbar">
<SortTrigger column-id="salary" label="Salary" />
<ColumnFilterPopover column-id="department" type="enum" />
<span>{{ total }} rows</span>
</header>
<!-- Cards, not a <table> — the primitives do not care what wraps them. -->
<article v-for="row in pageRows" :key="row.id" class="card">
<strong>{{ row.name }}</strong> — {{ row.department }}
</article>
<TablePagination />
</TableRoot>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.card {
border: 1px solid #ddd;
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 6px;
}
</style>One language for the whole app
app.use(createTableLabels(ru)) in main.ts and every table in the app renders Russian with no labels prop anywhere — the preset, the primitives underneath it, and a bare <TablePagination> with no table above it. Hand the plugin a ref or a getter instead of a record and a language switch re-renders them all in place. A labels prop still wins where one is given, and wins per key: it is merged over the app's record rather than over English — see Labels and i18n.
<script setup lang="ts">
import { createApp, defineComponent, h, onBeforeUnmount, onMounted, ref, shallowRef, type App } from 'vue'
import {
DataTable,
createTableLabels,
useLocalDataSource,
useTableState,
type ColumnDef,
} from '@brillliand/vue-table-chad'
import { ru } from '@brillliand/vue-table-chad/locales'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; salary: number }
const columns: ColumnDef<Person>[] = [
// Headers are the caller's own copy, so the app's i18n owns them. The label
// record covers what the library writes: the search box, the pager, the
// filter panels, the accessible names.
{ id: 'name', header: 'Имя', type: 'text' },
{
id: 'department',
header: 'Отдел',
type: 'enum',
options: ['Инженерия', 'Дизайн', 'Продажи', 'Поддержка'],
},
{
id: 'salary',
header: 'Зарплата',
type: 'number',
align: 'right',
format: (v) => (v == null ? '—' : `${Number(v).toLocaleString('ru-RU')} ₽`),
},
]
/*
A plain table. No `labels` prop, no locale imported here — this is what every
table in the app looks like once the plugin is installed.
*/
const Table = defineComponent({
setup() {
const rows = shallowRef<Person[]>(
Array.from({ length: 120 }, (_, i) => ({
id: i + 1,
name: `Сотрудник ${i + 1}`,
department: ['Инженерия', 'Дизайн', 'Продажи', 'Поддержка'][i % 4]!,
salary: 50_000 + ((i * 7919) % 90_000),
})),
)
const state = useTableState({ pageSize: 8 })
const source = useLocalDataSource(rows, columns, state.query)
return () =>
h(DataTable as never, { columns, source, state, showSearch: true, selectable: 'multiple' })
},
})
/*
In an app of your own, that is the whole recipe — one line in `main.ts`:
createApp(App).use(createTableLabels(ru)).mount('#app')
This page is already running inside an app it does not own, so the same line
runs against a second one mounted into the div below. Nothing else changes:
`Table` above is handed no wording at all and renders Russian, and so would a
bare `<TablePagination>` with no table above it.
Hand the plugin a ref or a getter instead of `ru` and a language switch
re-renders every table in place. A `labels` prop still wins where one is
given, and wins per key — it is merged over this record rather than over
English, so rewording one string in one table keeps the rest of the locale.
*/
const host = ref<HTMLElement | null>(null)
let app: App | null = null
onMounted(() => {
if (!host.value) return
app = createApp(Table)
app.use(createTableLabels(ru))
app.mount(host.value)
})
onBeforeUnmount(() => {
app?.unmount()
app = null
})
</script>
<template>
<div ref="host"></div>
</template>Live: the Recipes tab of pnpm demo (#recipes). Back to the docs index.