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.
Sticky groups
Section titled “Sticky groups”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.
Resizing inside a group
Section titled “Resizing inside a group”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.
Reordering with groups
Section titled “Reordering with groups”Two reorder gestures coexist:
- Move a whole group.
DraggableGroupHeader(from the shadcncolumn-reorderregistry) 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.
ReorderGripon 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
ReorderGriponly 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.
Nesting
Section titled “Nesting”<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.
Layout notes
Section titled “Layout notes”- Each nesting level adds a full header row. Keep group labels short and the row height modest —
text-xswith 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.