Skip to content

Column Groups

ColumnGroup wraps adjacent columns and renders one shared header above them — “Inventory” sitting above stock and status, “Fulfillment” above region and category. In a wide table, the grouped header gives the reader a second-tier band to scan instead of counting columns from the left edge to locate a field. Use it when neighbouring columns belong together as a named bundle (inventory metrics, fulfillment details, customer attributes) and the table is wide enough that the bundle isn’t obvious from column order alone.

import { localModel } from '@virtuoso.dev/data-table'
import {
  ColumnGroup,
  ColumnGroupHeader,
  DataTable,
  DataTableCell,
  DataTableColumn,
  DataTableColumnHeader,
} from '@/components/ui/data-table'

const rows = [
  { name: 'Standing Desk', category: 'Office', stock: 14, status: 'Ready', region: 'US' },
  { name: 'USB-C Dock', category: 'Device', stock: 42, status: 'Ready', region: 'EU' },
  { name: 'Keyboard', category: 'Device', stock: 8, status: 'Low', region: 'APAC' },
]

const model = localModel({ data: rows })
const groupHeaderClassName = 'flex h-8 items-center justify-center bg-muted/40 px-2 text-xs font-medium text-muted-foreground'

export default function App() {
  return (
    <DataTable className="rounded-xl" model={model} style={{ height: 300 }}>
      <ColumnGroup>
        <ColumnGroupHeader className={groupHeaderClassName}>{() => 'Catalog'}</ColumnGroupHeader>
        <DataTableColumn field="name">
          <DataTableColumnHeader>Product</DataTableColumnHeader>
          <DataTableCell className="font-medium">{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </ColumnGroup>
      <ColumnGroup>
        <ColumnGroupHeader className={groupHeaderClassName}>{() => 'Inventory'}</ColumnGroupHeader>
        <DataTableColumn field="stock">
          <DataTableColumnHeader className="justify-end">Stock</DataTableColumnHeader>
          <DataTableCell className="text-right tabular-nums">{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="status">
          <DataTableColumnHeader>Status</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </ColumnGroup>
      <ColumnGroup>
        <ColumnGroupHeader className={groupHeaderClassName}>{() => 'Fulfillment'}</ColumnGroupHeader>
        <DataTableColumn field="region">
          <DataTableColumnHeader>Region</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="category">
          <DataTableColumnHeader>Category</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </ColumnGroup>
    </DataTable>
  )
}

Grouping changes only the header. Each column inside a group keeps its own field, cell renderer, width, visibility, and saved state — the body grid never notices the grouping exists. That separation is what makes the next sections work: every per-column feature still applies, with a few group-level twists worth knowing.

Pin a whole group with <ColumnGroup sticky="left"> (or "right"). Every column inside follows the group to the edge during horizontal scroll — it’s a shorthand for declaring sticky="left" on each child, and the group header stays anchored above its columns.

<ColumnGroup sticky="left">
  <ColumnGroupHeader>{() => 'Identity'}</ColumnGroupHeader>
  <DataTableColumn field="id">…</DataTableColumn>
  <DataTableColumn field="name">…</DataTableColumn>
</ColumnGroup>

Only top-level groups can be sticky — sticky on a nested <ColumnGroup> is ignored. Children inherit sticky-ness from the nearest sticky ancestor, so nested groups don’t need their own opt-in.

Resize handles work unchanged on columns inside groups. Each column owns its width override; the group header automatically re-sums from its children on every resize, so dragging “Stock” wider stretches the “Inventory” band to match. You never set a width on the group itself — there’s no group-level resize API, because distributing pixels across child columns is rarely what users mean when they grab a header.

Two reorder gestures coexist:

  • Move a whole group. DraggableGroupHeader (from the shadcn column-reorder registry) makes the group header itself draggable. The user drags “Inventory” between “Catalog” and “Fulfillment” and all of Inventory’s columns travel together, keeping their internal order.
  • Move one column. ReorderGrip on individual column headers keeps working — useful for nudging a column within its group.

Both gestures respect sticky regions: a left-sticky source can only land among other left-sticky targets, and so on. That guard is built into the registry components, so you can drop them in without filtering by hand.

There’s a footgun in mixing the two: a single-column drag that crosses a group boundary breaks the visual layout. The header tree pairs each column with its declared <ColumnGroup> regardless of where it now sits in column order, so a column dragged out of group A and dropped between group B’s columns produces a body row in the new order but a header band that’s no longer contiguous. The whole-group drag never has this problem — it moves every key in one transaction.

Two practical responses:

  • Mount ReorderGrip only on ungrouped columns (or only inside groups, never both), so users can’t initiate a cross-group single-column drag.
  • Or keep both gestures and accept that cross-group moves split the band. Works for tables where the grouping is informational rather than structural.

reorderColumnGroup$ is the underlying engine action if you wire your own group-drag UI; DraggableGroupHeader publishes to it. See Column Reordering for the full reorder surface.

<ColumnGroup> can wrap other <ColumnGroup> declarations for two-tier categorization — “Customer” above “Personal” and “Account”, each above their own columns:

<ColumnGroup>
  <ColumnGroupHeader>{() => 'Customer'}</ColumnGroupHeader>
  <ColumnGroup>
    <ColumnGroupHeader>{() => 'Personal'}</ColumnGroupHeader>
    <DataTableColumn field="name">…</DataTableColumn>
    <DataTableColumn field="email">…</DataTableColumn>
  </ColumnGroup>
  <ColumnGroup>
    <ColumnGroupHeader>{() => 'Account'}</ColumnGroupHeader>
    <DataTableColumn field="plan">…</DataTableColumn>
  </ColumnGroup>
</ColumnGroup>

Each nesting level stacks one extra header row. Two levels usually covers what’s worth grouping; deeper hierarchies steal vertical space the data needs.

  • Each nesting level adds a full header row. Keep group labels short and the row height modest — text-xs with small padding is the usual register.
  • Group headers render full-width over their span. The label sits inside that bar, so very long labels either wrap or get clipped depending on your CSS.