DataTable
Headless compound component for rendering tabular data with sorting, pagination, selection, and expansion support.
Usage
DataTable.Root creates the table. DataTable.Column and DataTable.Row register when they mount and unregister when they unmount — same lifecycle as Checkbox.Group. context.items is the pipeline over those registered rows. The client adapter defaults to 10 rows per page[1]; DataTable.Row hides off-page rows itself so they stay registered. Compose Pagination or pass :pagination="{ itemsPerPage: n }" on Root.
| Alice Johnson | alice@example.com | Admin |
| Bob Smith | bob@example.com | Editor |
| Carol White | carol@example.com | Viewer |
| David Brown | david@example.com | Editor |
| Eve Davis | eve@example.com | Admin |
Anatomy
<script setup lang="ts">
import { DataTable } from '@vuetify/v0'
</script>
<template>
<DataTable.Root>
<DataTable.Table>
<DataTable.Header>
<DataTable.Row>
<DataTable.Column />
</DataTable.Row>
</DataTable.Header>
<DataTable.Body>
<DataTable.Row>
<DataTable.Cell />
</DataTable.Row>
<DataTable.Empty>
<DataTable.Cell />
</DataTable.Empty>
</DataTable.Body>
</DataTable.Table>
</DataTable.Root>
</template>Architecture
The DataTable compound is a thin shell over createDataTable. Root creates the instance; Column and Row register as children, like Checkbox.Group. v-for="user in rank(users)" — rank is on the Body slot. Row hides off-page rows after it registers — don’t add a consumer v-show.
Data Loading
Put a DataTable.Row in the DOM for each row and a DataTable.Column for each column. They register on setup and unregister on unmount. Pass :value on data rows. v-for="user in rank(users)" — rank is on the Body slot, ranks the source by the pipeline. Don’t v-if off-page rows — Row already v-shows them so they stay registered.
| File | Role |
|---|---|
useLoading.ts | Composable — user seed |
LoadingTable.vue | Reusable table — children register on render, Row hides the page, pager |
loading.vue | Entry — wires the seed to the table |
| Alice Johnson | Admin |
| Bob Smith | Editor |
| Carol White | Viewer |
For pipeline-only use without the compound, call createDataTable and onboard on the returned context.
Examples
16 people. Search filters the registry; sort reorders what you see.
AJ Alice Johnson | alice@example.com | Admin | Platform |
BS Bob Smith | bob@example.com | Editor | Docs |
CW Carol White | carol@example.com | Viewer | Design |
DB David Brown | david@example.com | Editor | Platform |
| Default page size hides rows | open | Alice | |
| aria-rowcount includes header rows | open | Bob | |
| Column sort should follow sortedItems | done | Carol | |
| Search keys come from filterable columns | open | David | |
| Select-all operates on the current page | open | Eve | |
| v-if unregisters off-page rows | done | Frank |
Recipes
Search
Bind v-model:search on Root — same shape as Pagination.Root’s v-model. Column filterable flags which fields the query matches.
<template>
<DataTable.Root v-model:search="query">
<input v-model="query" type="search" aria-label="Search">
<!-- table markup -->
</DataTable.Root>
</template>Sorting
DataTable.Column exposes sort state when given an id. sortable / filterable are live getters on the registered ticket, like disabled on Tabs.Item. v-for="user in rank(users)" so toggling sort reorders the rows:
| Slot prop | Type | Description |
|---|---|---|
isSortable | boolean | Whether the column is sortable |
direction | 'asc' | 'desc' | 'none' | Current sort direction |
priority | number | Sort priority for multi-sort (-1 if not sorted) |
toggle | () => void | Toggle sort on this column |
Selection
DataTable.Row exposes selection state when given an id:
| Slot prop | Type | Description |
|---|---|---|
id | ID | undefined | Registered row id |
value | object | undefined | Registered row record. Undefined on header rows. |
isSelected | boolean | Whether the row is selected |
isSelectable | boolean | Whether the row can be selected |
isVisible | boolean | Whether this data row is on the current page. Header rows are always visible. |
toggleSelection | () => void | Toggle row selection |
Expansion
DataTable.Row also exposes expansion state:
| Slot prop | Type | Description |
|---|---|---|
isExpanded | boolean | Whether the row is expanded |
toggleExpansion | () => void | Toggle row expansion |
Bind :id to the same id the row registered with (DataTable.Row’s id prop), not a field on the row value unless they are the same.
Pagination
The compound has no pager. Drive context.pagination yourself, or compose Pagination:
<template>
<DataTable.Root v-slot="{ context }" :pagination="{ itemsPerPage: 10 }">
<!-- table markup; Row hides off-page rows after it registers[^collapse] -->
<Button.Root :disabled="context.pagination.isFirst.value" @click="context.pagination.prev()">
Previous
</Button.Root>
<span>{{ context.pagination.page.value }} / {{ context.pagination.pages }}</span>
<Button.Root :disabled="context.pagination.isLast.value" @click="context.pagination.next()">
Next
</Button.Root>
</DataTable.Root>
</template>Virtualization
Every row this compound registers has to stay mounted. That is correct for a page of results and the wrong shape for thousands of rows — unmounting a row to virtualize it also unregisters it, so totals and sort/filter state collapse to the viewport.
Use createDataTable with VirtualDataTableAdapter and wrap table.items in createVirtual.[2] The adapter filters and sorts without slicing pages; createVirtual mounts only the visible window. Tickets stay on the registry whether a row is on screen or not.
<script setup lang="ts">
import { createDataTable, createVirtual } from '@vuetify/v0'
import { VirtualDataTableAdapter } from '@vuetify/v0/data-table/adapters/virtual'
const table = createDataTable({
adapter: new VirtualDataTableAdapter(),
})
table.columns.onboard(columns)
table.onboard(users.map(value => ({ id: value.id, value })))
const { element, items: visible, offset, size, scroll } = createVirtual(table.items, {
itemHeight: 40,
})
</script>
<template>
<div ref="element" class="h-[400px] overflow-y-auto" @scroll="scroll">
<div :style="{ height: `${offset}px` }" />
<div v-for="item in visible" :key="item.index">
{{ item.raw.name }}
</div>
<div :style="{ height: `${size}px` }" />
</div>
</template>See the virtual scrolling example for a full table with sticky headers. When the API owns filter, sort, and page, use ServerDataTableAdapter and onboard each response instead of keeping a client-side window.
| Dataset | Loading | Render |
|---|---|---|
| Fits in the page | Children register | v-for the source; Row hides off-page rows |
| Fits in the client, not the DOM | onboard + VirtualDataTableAdapter | createVirtual(table.items) |
| Doesn’t fit in the client | ServerDataTableAdapter + onboard the page | The page the API returned |
Accessibility
DataTable renders semantic table markup with ARIA attributes:
DataTable.Tablerenders<table role="table">. Name it witharia-labelor a<caption>— Root is a fragment and cannot be named.aria-rowcountis set only when the current page is a subset of total. The count includes header rows.DataTable.Rowsetsaria-rowindexfrom its position insortedItemsunless:indexis passed.DataTable.Columnrenders<th role="columnheader">witharia-sortfor sortable columnsDataTable.Rowrenders<tr role="row">witharia-selectedwhenselectableis setDataTable.Cellrenders<td role="cell">
Put a Button.Root inside sortable header cells — do not make the <th> itself the control:
<template>
<DataTable.Column
id="name"
v-slot="{ isSortable, toggle }"
>
<Button.Root v-if="isSortable" @click="toggle">
Name
</Button.Root>
<span v-else>Name</span>
</DataTable.Column>
</template>FAQ
Use DataTable when you want semantic table markup and ARIA, with rows and columns as children. Use createDataTable when the dataset is not the DOM — cards, virtual lists, or onboard of a server page.
context.items and context.sortedItems are arrays of the records you registered — the same objects passed as :value. DataTable.Row also exposes that record as value on its slot, including when the row was registered with onboard first.
No. Collection composables don’t take an items factory option, and neither does Root. Children register from the DOM, or you call onboard on a createDataTable instance.
No. items is derived from registered rows. First paint is empty, nothing registers, the table stays empty. v-for="user in rank(users)" so rows mount from the source.
Visibility lives on DataTable.Row after it registers. Body’s items is empty on the first SSR pass, so a consumer v-show="items.some(...)" hides every row in the HTML. Don’t v-if off-page rows either — that unregisters the ticket, so total and page counts shrink to the visible page.
itemsPerPage: 10is the createPagination default the client adapter ships. Pass:pagination="{ itemsPerPage: n }"on Root, orInfinityfor a single page — off-page rows stay mounted; Row hides them. ↩︎A Virtualizer compound is planned as a scroll viewport over
createVirtual. It is not required to virtualize a table today —createVirtualis the render layer. ↩︎