Skip to main content
Vuetify0 v1.0 is here
Vuetify0 Logo
Theme
Mode
Palettes
Accessibility
Vuetify One
Sign in to Vuetify One

Access premium tools across the Vuetify ecosystem — Bin, Play, Studio, and more.

Not a subscriber? See what's included

DataTable

Headless compound component for rendering tabular data with sorting, pagination, selection, and expansion support.

Edit this page
Report a Bug
Open issues
View on GitHub
Copy Markdown

PreviewRenders elementIntermediateAug 25, 2026

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 Johnsonalice@example.comAdmin
Bob Smithbob@example.comEditor
Carol Whitecarol@example.comViewer
David Browndavid@example.comEditor
Eve Daviseve@example.comAdmin
Theme
Mode
Palettes
Accessibility
<script setup lang="ts">
  import { Button, DataTable } from '@vuetify/v0'
  import { shallowRef } from 'vue'

  interface User {
    id: number
    name: string
    email: string
    role: string
  }

  const query = shallowRef('')

  const users: User[] = [
    { id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
    { id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'Editor' },
    { id: 3, name: 'Carol White', email: 'carol@example.com', role: 'Viewer' },
    { id: 4, name: 'David Brown', email: 'david@example.com', role: 'Editor' },
    { id: 5, name: 'Eve Davis', email: 'eve@example.com', role: 'Admin' },
  ]

  const columns = [
    { id: 'name', title: 'Name', sortable: true },
    { id: 'email', title: 'Email', sortable: true, filterable: true },
    { id: 'role', title: 'Role', sortable: true },
  ]

</script>

<template>
  <DataTable.Root v-model:search="query">
    <div class="mb-4">
      <input
        v-model="query"
        aria-label="Search users"
        class="border rounded-md px-3 py-2 w-64"
        placeholder="Search..."
        type="text"
      >
    </div>

    <DataTable.Table aria-label="Users table" class="w-full border-collapse">
      <DataTable.Header>
        <DataTable.Row class="border-b">
          <DataTable.Column
            v-for="col in columns"
            :id="col.id"
            :key="col.id"
            v-slot="{ isSortable, toggle, direction }"
            class="text-left p-3 font-semibold"
            :filterable="col.filterable"
            :sortable="col.sortable"
          >
            <Button.Root
              v-if="isSortable"
              class="flex items-center gap-1 hover:text-primary"
              @click="toggle"
            >
              {{ col.title }}
              <span v-if="direction !== 'none'" class="text-xs font-normal opacity-60">{{ direction }}</span>
            </Button.Root>

            <span v-else>{{ col.title }}</span>
          </DataTable.Column>
        </DataTable.Row>
      </DataTable.Header>

      <DataTable.Body v-slot="{ rank }">
        <DataTable.Row
          v-for="user in rank(users)"
          :id="user.id"
          :key="user.id"
          class="border-b hover:bg-surface-variant"
          :value="user"
        >
          <DataTable.Cell class="p-3">{{ user.name }}</DataTable.Cell>
          <DataTable.Cell class="p-3">{{ user.email }}</DataTable.Cell>
          <DataTable.Cell class="p-3">{{ user.role }}</DataTable.Cell>
        </DataTable.Row>

        <DataTable.Empty v-slot="{ columnCount }">
          <DataTable.Cell class="p-6 text-center text-on-surface-variant" :colspan="columnCount">
            No users found
          </DataTable.Cell>
        </DataTable.Empty>
      </DataTable.Body>
    </DataTable.Table>
  </DataTable.Root>
</template>

Anatomy

vue
<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.

Component Flow

Use controls to zoom and pan. Click outside or press Escape to close.

Component Flow

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.

FileRole
useLoading.tsComposable — user seed
LoadingTable.vueReusable table — children register on render, Row hides the page, pager
loading.vueEntry — wires the seed to the table
Alice JohnsonAdmin
Bob SmithEditor
Carol WhiteViewer
David BrownEditor
Eve DavisAdmin
Frank MillerViewer
1 / 0
Theme
Mode
Palettes
Accessibility
Tip

For pipeline-only use without the compound, call createDataTable and onboard on the returned context.

Examples

Team directory

A client-side roster that shows the loading path the compound is built for: each DataTable.Column and DataTable.Row registers on setup, v-model:search drives the filter pipeline, and body rows v-for="member in rank(members)" so a header click reorders the table without replacing the collection. Off-page rows stay mounted — Row hides them after register, so a consumer v-show against Body’s items is not required (items is empty on the first SSR pass). v-if would unregister them and the pager would lie about totals. The name cell composes Avatar; members without an image fall through to initials.

The pager is context.pagination; swap those two buttons for Pagination if you want numbered page items, but keep a single page owner.

Reach for this whenever the dataset fits in the client and you want a table you can copy into an app. The trade-off versus createDataTable plus onboard is that every row you might page to has to stay in the DOM. Huge lists belong on the server adapter or a virtualizer; this example is the default path. Related: createFilter is the search stage, and createPagination is the page stage.

FileRole
useTeam.tsComposable — member seed, search query, and column definitions
TeamTable.vueReusable table — avatars, search, sortable headers, pager
team-directory.vueEntry — wires the composable to the table and a short status line

16 people. Search filters the registry; sort reorders what you see.

AJ
Alice Johnson
alice@example.comAdminPlatform
BS
Bob Smith
bob@example.comEditorDocs
CW
Carol White
carol@example.comViewerDesign
DB
David Brown
david@example.comEditorPlatform
1 / 0
Theme
Mode
Palettes
Accessibility

Issue list with row selection

A triage table that uses the table’s own selection set instead of wrapping rows in Checkbox.Group. DataTable.Row with selectable exposes isSelected / toggleSelection and writes data-selected on the <tr>, so row chrome stays declarative. The header “Toggle page” calls context.selection.toggleAll() — with the default selectStrategy: 'page' that means the visible rows, not the whole registry.

Archive reads selectedIds, drops those issues from the source array, then unselectAll(). Because the table v-fors that source array, those rows unmount and unregister — same as removing a Checkbox.Root from a group. Search uses v-model:search against filterable title and assignee columns; DataTable.Empty covers both a failed query and a fully archived list.

Reach for this when the grid is the selection surface — issue trackers, file lists, anything with bulk actions. If you need a tri-state header checkbox that looks like the rest of your forms, compose Checkbox.Root as the cell visual and keep toggleSelection as the writer; don’t run a parallel Checkbox.Group v-model next to context.selection. Related: createGroup for the checkbox pattern, and selectStrategy of 'single' / 'all' when page-scoped select-all is the wrong unit.

FileRole
useIssues.tsComposable — issue seed, archive/reset, and status copy
IssueTable.vueReusable table — page toggle, archive action, selectable rows, sortable columns
issue-selection.vueEntry — wires the composable to the table and a reset control
Default page size hides rowsopenAlice
aria-rowcount includes header rowsopenBob
Column sort should follow sortedItemsdoneCarol
Search keys come from filterable columnsopenDavid
Select-all operates on the current pageopenEve
v-if unregisters off-page rowsdoneFrank
6 open in the list
Theme
Mode
Palettes
Accessibility

Recipes

Bind v-model:search on Root — same shape as Pagination.Root’s v-model. Column filterable flags which fields the query matches.

vue
<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 propTypeDescription
isSortablebooleanWhether the column is sortable
direction'asc' | 'desc' | 'none'Current sort direction
prioritynumberSort priority for multi-sort (-1 if not sorted)
toggle() => voidToggle sort on this column

Selection

DataTable.Row exposes selection state when given an id:

Slot propTypeDescription
idID | undefinedRegistered row id
valueobject | undefinedRegistered row record. Undefined on header rows.
isSelectedbooleanWhether the row is selected
isSelectablebooleanWhether the row can be selected
isVisiblebooleanWhether this data row is on the current page. Header rows are always visible.
toggleSelection() => voidToggle row selection

Expansion

DataTable.Row also exposes expansion state:

Slot propTypeDescription
isExpandedbooleanWhether the row is expanded
toggleExpansion() => voidToggle 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:

vue
<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.

vue
<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.

DatasetLoadingRender
Fits in the pageChildren registerv-for the source; Row hides off-page rows
Fits in the client, not the DOMonboard + VirtualDataTableAdaptercreateVirtual(table.items)
Doesn’t fit in the clientServerDataTableAdapter + onboard the pageThe page the API returned

Accessibility

DataTable renders semantic table markup with ARIA attributes:

  • DataTable.Table renders <table role="table">. Name it with aria-label or a <caption> — Root is a fragment and cannot be named.

  • aria-rowcount is set only when the current page is a subset of total. The count includes header rows. DataTable.Row sets aria-rowindex from its position in sortedItems unless :index is passed.

  • DataTable.Column renders <th role="columnheader"> with aria-sort for sortable columns

  • DataTable.Row renders <tr role="row"> with aria-selected when selectable is set

  • DataTable.Cell renders <td role="cell">

Put a Button.Root inside sortable header cells — do not make the <th> itself the control:

vue
<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

Discord
Need help? Join our community for support and discussions ↗

  1. itemsPerPage: 10 is the createPagination default the client adapter ships. Pass :pagination="{ itemsPerPage: n }" on Root, or Infinity for a single page — off-page rows stay mounted; Row hides them. ↩︎

  2. A Virtualizer compound is planned as a scroll viewport over createVirtual. It is not required to virtualize a table today — createVirtual is the render layer. ↩︎

Was this page helpful?

© 2016-1970 Vuetify, LLC
Services
Ctrl+/