Skip to content

Composing your own ​

120 rows
Person 1 — Engineering
Person 2 — Design
Person 3 — Sales
Person 4 — Support
Person 5 — Engineering
Person 6 — Design
Person 7 — Sales
Person 8 — Support
Person 9 — Engineering
Person 10 — Design
vue
<script setup lang="ts">
// No preset, no stylesheet import — this page's own CSS (below, scoped) is
// the only styling anywhere in this file, which is the whole "headless buys
// you zero CSS" contract from CLAUDE.md.
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>

The TableRoot slot hands you everything; the markup is yours. This renders cards, not a table, using the same sort triggers, filter popovers and pager as the preset:

vue
<TableRoot v-slot="{ rows, selection, total }" :columns="columns" :source="source" selectable>
  <SortTrigger column-id="salary" label="Salary" />
  <ColumnFilterPopover column-id="department" type="enum" />

  <article v-for="row in rows" :key="row.id" @click="selection.toggle(row)">
    {{ row.name }}
  </article>

  <TablePagination />
</TableRoot>

Every primitive also takes explicit props that override the injected context, so it works with no TableRoot at all:

vue
<!-- a standalone pager for any list -->
<TablePagination :page="page" :page-size="20" :total="count" @update:page="page = $event" />

The live example at the top of this page is that shape with cards; the fully standalone form — no TableRoot at all, every primitive fed by props — is docs/examples/ComposedFromPrimitives.vue, walked through in Porting an existing table.

The primitives, and what each one slots ​

Every primitive reads the table context with useTableContext(), which returns undefined with no TableRoot above, and then prefers whatever explicit props you passed — which is what makes each of them usable standalone. Three read a whole model rather than a value and cannot do that: ColumnVisibilityMenu, RowGroupMenu and ActiveFilters call requireTableContext() instead and throw a named error outside a root.

The slots below are the ones the preset's own slot table does not show, because DataTable fills them itself. Reach for them when you assemble the rows and headers from the primitives directly:

PrimitiveSlotPropsWhat it replaces
TableRowleadingrow, selectedThe first cell — the preset puts the selection checkbox here. Rendered only when the slot is given, so the grid stays aligned with a <colgroup> that has no column for it.
TableRowtrailingrow, stateThe last cell — the preset's row-edit Save/Cancel. Same rule: no slot, no cell.
TableRowcellrow, column, value, text, indexPer-cell content, for every column at once; the preset routes its cell:<id> slots through it.
TableHeaderCelldefaultcolumn, grouped, groupsCollapsed, foldThe header label. fold is the call a grouped column's click makes.
TableHeaderCellresizecolumnWhere the preset mounts ColumnResizeHandle. Left empty, the column has no drag handle.
TableGroupRowaggregatecolumn, result, textOne aggregate figure inside a group header row.
TablePaginationsummarypagination, totalThe "X–Y of Z" text. pagination is the whole UsePagination, so firstRow and lastRow are there.

Two primitives carry no slot of their own worth naming and are easy to miss. TableCell is one <td> sharing the header's sticky and pin logic, so a hand-built row that uses it stays aligned with a pinned header; TableRow renders one per column, and a row built without TableRow can render them itself. ColumnResizeHandle is the pointer-and-keyboard resizer, a separator role that emits resize(columnId, width) and reads the width to start from off the header cell when the column declared none.

Without a component at all — useTable() ​

TableRoot is a thin thing: it calls useTable(), publishes the result with provideTableContext(), and renders a slot. When you want the wiring but not the component — a table whose markup shares nothing with the preset's, or several tables in an app that each assemble their own — call it directly.

ts
const table = useTable({
  columns: () => columns,
  source: () => source,
  state,                       // optional; it builds one if you don't
  selectable: () => true,
})

Options are getters wherever the value can change — a composable has no props to watch, so you supply the read. The rest are read once at setup, and UseTableOptions says which are which.

What comes back is the TableContext every primitive reads, plus headerRows, cursor, rowSelection and getRowKey. Publish it if primitives beneath need to find it:

ts
provideTableContext(table)

Everything the preset does about grouping, selection gating, cursor seeding and layout persistence happens here, so a hand-built table gets those rules rather than reimplementing them. What stays with the component is what only a component can do: provide, and turning a change into an emit.

TRow is unconstrained, so an ordinary interface Row { … } works — no index signature needed.

Hoisting state (URL, store) ​

Pass a ref and the table stops owning its state — it reads and writes yours:

ts
const external = ref<QueryState>(readFromUrl())
const state = useTableState({ state: external })

watch(external, (q) => history.replaceState(null, '', `#q=${encodeURIComponent(JSON.stringify(q))}`),
  { deep: true })

Mirroring is synchronous both ways, so reading external.value right after state.setPage(3) gives you page 3.

Sort, filter or page the table below and the address bar follows; press back and the table follows. Nothing in the table knows the URL exists:

{"sort":[],"filters":{},"groupBy":[],"page":1,"pageSize":10,"globalSearch":""}

Person 1Engineering$50,0002015-01-01
Person 2Design$57,9192016-02-02
Person 3Sales$65,8382017-03-03
Person 4Support$73,7572018-04-04
Person 5Engineering$81,6762019-05-05
Person 6Design$89,5952020-06-06
Person 7Sales$97,5142021-07-07
Person 8Support$105,4332022-08-08
Person 9Engineering$113,3522023-09-09
Person 10Design$121,2712015-10-10
vue
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import {
  DataTable,
  createQueryState,
  pruneFilters,
  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(600))

/*
 * `?q=` rather than the hash: this page is one of many on a VitePress site,
 * and writing the hash would send the reader to whatever anchor it named.
 * `replaceState` writes the URL without navigating either way.
 */
const PARAM = 'q'

function readFromUrl(): QueryState {
  const raw = new URLSearchParams(location.search).get(PARAM)
  if (raw) {
    try {
      return { ...createQueryState({ pageSize: 10 }), ...JSON.parse(raw) }
    } catch {
      // A hand-edited or truncated URL must not brick the page.
    }
  }
  return createQueryState({ pageSize: 10 })
}

/**
 * The single source of truth. `useTableState({ state })` stops owning anything
 * and mirrors this ref instead — both ways, and synchronously, so reading it
 * straight after `state.setPage(3)` gives you page 3 rather than the old one.
 */
const external = ref<QueryState>(createQueryState({ pageSize: 10 }))

const state = useTableState({ state: external })
const source = useLocalDataSource(rows, columns, state.query)

/** `pruneFilters` drops the cleared ones, so the URL carries no empty filters. */
const serialized = computed(() =>
  JSON.stringify({ ...external.value, filters: pruneFilters(external.value.filters) }),
)

watch(serialized, (value) => {
  const params = new URLSearchParams(location.search)
  params.set(PARAM, value)
  history.replaceState(null, '', `${location.pathname}?${params}`)
})

/** The other direction: back, forward, or someone pasting a link. */
function onPopState(): void {
  external.value = readFromUrl()
}

/*
 * The URL is read on mount rather than during setup: this page is prerendered,
 * and `location` does not exist on the server. Reading it here means the first
 * client render matches the server's and the URL is applied a tick later.
 */
onMounted(() => {
  external.value = readFromUrl()
  window.addEventListener('popstate', onPopState)
})
onUnmounted(() => window.removeEventListener('popstate', onPopState))

/** Proof the ref is the real owner: this bypasses the table entirely. */
function jumpToPage3(): void {
  external.value = { ...external.value, page: 3 }
}
</script>

<template>
  <p>
    <button type="button" @click="jumpToPage3()">Write page 3 into the ref</button>
    <code>{{ serialized }}</code>
  </p>

  <!-- Nothing below knows the URL exists. -->
  <DataTable :columns="columns" :source="source" :state="state" />
</template>

Live: the Composed tab of pnpm demo (#composed). Back to the docs index.