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

DataGrid

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

PreviewRenders elementIntermediateAug 25, 2026

Headless compound for tabular data with column layout, cell editing, row ordering, and row spanning.

Usage

DataGrid.Root creates the grid. DataGrid.Column and DataGrid.Row register when they mount and unregister when they unmount — same lifecycle as Checkbox.Group. A resizable header row composes Splitter so DataGrid.Handle resizes columns. v-for="user in rank(users)". Non-renderless Row v-shows off-page rows — don’t add a consumer v-show. Renderless: v-show="isVisible" on your host. The client adapter defaults to 10 rows per page. 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
Theme
Mode
Palettes
Accessibility
<script setup lang="ts">
  import { Button, DataGrid } 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' },
  ]

  const columns = [
    { id: 'name', title: 'Name', size: 34, minSize: 16, sortable: true, filterable: false },
    { id: 'email', title: 'Email', size: 42, minSize: 20, sortable: true, filterable: true },
    { id: 'role', title: 'Role', size: 24, minSize: 12, sortable: true, filterable: false },
  ] as const
</script>

<template>
  <DataGrid.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>

    <DataGrid.Table
      aria-label="Users"
      as="div"
      class="w-full border border-divider rounded-lg overflow-hidden"
    >
      <DataGrid.Header as="div" class="bg-surface-tint">
        <DataGrid.Row as="div" class="flex" resizable>
          <DataGrid.Column
            v-for="(col, i) in columns"
            :id="col.id"
            :key="col.id"
            v-slot="{ isSortable, toggle, direction }"
            as="div"
            class="relative p-3 text-start font-semibold"
            :filterable="col.filterable"
            :min-size="col.minSize"
            :size="col.size"
            :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>

            <DataGrid.Handle
              v-if="i < columns.length - 1"
              class="absolute inset-y-0 right-0 z-10 group"
              :label="'Resize ' + col.title"
            >
              <div class="absolute inset-y-0 -left-1 w-2 cursor-col-resize flex justify-center">
                <div class="w-px h-full bg-divider group-hover:w-0.5 group-hover:bg-primary group-data-[state=drag]:w-0.5 group-data-[state=drag]:bg-primary" />
              </div>
            </DataGrid.Handle>
          </DataGrid.Column>
        </DataGrid.Row>
      </DataGrid.Header>

      <DataGrid.Body v-slot="{ rank }" as="div">
        <DataGrid.Row
          v-for="user in rank(users)"
          :id="user.id"
          :key="user.id"
          as="div"
          class="flex hover:bg-surface-tint/50 border-t border-divider"
          :value="user"
        >
          <DataGrid.Cell
            v-for="(col, i) in columns"
            :key="col.id"
            as="div"
            class="p-3 truncate"
            :class="i < columns.length - 1 ? 'border-e border-divider' : undefined"
            :column="col.id"
          >
            {{ user[col.id] }}
          </DataGrid.Cell>
        </DataGrid.Row>

        <DataGrid.Empty v-slot="{ columnCount }" as="div">
          <DataGrid.Cell
            as="div"
            class="grow w-full basis-full p-6 text-center text-on-surface-variant"
            :colspan="columnCount"
          >
            No users found
          </DataGrid.Cell>
        </DataGrid.Empty>
      </DataGrid.Body>
    </DataGrid.Table>
  </DataGrid.Root>
</template>

Anatomy

vue
<script setup lang="ts">
  import { DataGrid } from '@vuetify/v0'
</script>

<template>
  <DataGrid.Root>
    <DataGrid.Table>
      <DataGrid.Header>
        <DataGrid.Row>
          <DataGrid.Column>
            <DataGrid.Handle />
          </DataGrid.Column>
        </DataGrid.Row>
      </DataGrid.Header>

      <DataGrid.Body>
        <DataGrid.Row>
          <DataGrid.Cell />
        </DataGrid.Row>

        <DataGrid.Empty>
          <DataGrid.Cell />
        </DataGrid.Empty>
      </DataGrid.Body>
    </DataGrid.Table>
  </DataGrid.Root>
</template>

Architecture

DataGrid extends DataTable with four additional capabilities:

  • Layout — Column pinning, sizing, resizing, and reordering via context.layout

  • Rows — Manual row ordering via context.rows.move() and context.rows.reset(). rank is on the Body slot, ranks by orderedItems so move() is visible; Row hides the page.

  • Editing — Cell editing state via context.editing

  • Spans — Row spanning via context.spans

Layout API

The context.layout API provides column sizing primitives:

  • layout.resize(columnId, delta) — Resize a column by a percentage delta (composable neighbor-delta API; Handle does not call this)

  • layout.distribute(sizes) — Set visible-column sizes at once in Column DOM/registry order (what a resizable row’s Splitter @layout calls). Not layout.columns pin order.

  • layout.columns — Reactive array of column layout state including size, minSize, maxSize

  • layout.pin(columnId, 'left' | 'right' | false) — Pin or unpin at runtime

DataGrid Context

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

DataGrid Context

Examples

Pinned columns

This is the compound surface DataTable does not have: left/right pinned columns with sticky offsets from layout.columns. Name starts pinned left, status right. The table is wider than the card — scroll sideways and those two stay put. L / R / · on a header cycles layout.pin(id, 'left' | 'right' | false). Body cells v-for the same display order so a newly pinned column actually moves, not just paints position: sticky in the middle of the row.

Sticky left/right must be pixels. Layout offsets are percentages of the table; a percentage inset resolves against the scrollport and pinned columns after the first drift. This grid is a fixed 1100px wide so offset% × 1100 is the inset — same math as the createDataGrid pinned example, which measures live table width instead.

Reach for this when a wide grid has columns the user must not lose while scanning. Resize with Splitter is a different path (as="div"); pinning uses the native table and sticky cells. Related: layout.pinned for the left/scrollable/right regions, and createDataGrid if you already own the table markup.

FileRole
useProjects.tsComposable — project seed and column defs (including initial pins)
ProjectGrid.vueReusable grid — sticky pins, pin cycle, horizontal scroll
pinned-columns.vueEntry — wires the seed to the grid

Name is pinned left, status right. Press L / R / · on a header to cycle pin. Scroll sideways.

Name
Owner
Team
Repo
Pts
Updated
Id
Status
Auth refreshAlicePlatformvuetifyjs/05Aug 211Doing
Docs rankingBobDocsvuetifyjs/docs3Aug 182Todo
Column pinCarolDesignvuetifyjs/08Aug 223Doing
Empty copyDavidDocsvuetifyjs/docs2Aug 124Done
Pager keysEvePlatformvuetifyjs/03Aug 195Todo
Sticky offsetFrankDesignvuetifyjs/05Aug 206Todo
Cell commitGracePlatformvuetifyjs/05Aug 227Doing
Row indexHenryDocsvuetifyjs/02Aug 118Done
Theme
Mode
Palettes
Accessibility

Accessibility

DataGrid ships structural table roles. It is not a WAI-ARIA Grid APG widget — there is no roving tabindex, aria-activedescendant, or keyboard cell navigation. Name it with aria-label or a <caption> — Root is a fragment and cannot be named.

  • DataGrid.Table renders <table role="table">. aria-rowcount is set only when the current page is a subset of total (count includes header rows). DataGrid.Row sets aria-rowindex from its position in orderedItems unless :index is passed. Header rows auto-number from Header child order; :index overrides.

  • DataGrid.Header and DataGrid.Body omit role on native thead/tbody; as="div" gets role="rowgroup".

  • DataGrid.Row renders with role="row" and aria-selected when selectable is set

  • DataGrid.Column renders with role="columnheader", scope="col", and aria-sort on sortable columns

  • DataGrid.Cell renders with role="cell" and rowspan (or aria-rowspan when as is not td) for spanned cells

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

vue
<template>
  <DataGrid.Column
    id="name"
    v-slot="{ isSortable, toggle }"
  >
    <Button.Root v-if="isSortable" @click="toggle">
      Name
    </Button.Root>
    <span v-else>Name</span>
  </DataGrid.Column>
</template>

DataGrid.Handle (inside a resizable row) inherits Splitter’s role="separator" semantics. Nest it inside DataGrid.Column so the separator is a descendant of columnheader, not a sibling under role="row" — a row may only own cell, columnheader, rowheader, or gridcell.

FAQ

Discord
Need help? Join our community for support and discussions ↗
Was this page helpful?

© 2016-1970 Vuetify, LLC
Services
Ctrl+/