Sticky Columns
Pass sticky="left" or sticky="right" to a DataTableColumn and it stays in place while the rest of the row scrolls under it horizontally. Use this to keep row-identifying columns (typically a product name) visible on the left, and status or action columns visible on the right, when the table is wide enough to scroll sideways.
Two layout constraints have to hold for the effect to work:
- Pinned columns need an explicit minimum width on the header (
min-w-[…]onDataTableColumnHeader). Without one, the column can collapse to its content width and overlap the scrollable cells sliding past it. See Column Resizing for why width belongs on the header, not the cell. - Every pinned column subtracts from the scrollable area. Pin one or two columns per edge — pinning more shrinks the scroll region until the feature stops earning its keep.
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { localModel } from '@virtuoso.dev/data-table'
const rows = Array.from({ length: 40 }, (_, index) => ({
id: `SKU-${String(index + 1).padStart(3, '0')}`,
name: `Product ${index + 1}`,
category: ['Office', 'Peripherals', 'Audio'][index % 3]!,
status: ['In stock', 'Low stock', 'Backorder'][index % 3]!,
owner: ['Avery', 'Morgan', 'Riley'][index % 3]!,
region: ['US', 'EU', 'APAC'][index % 3]!,
supplier: ['Northwind', 'Contoso', 'Fabrikam'][index % 3]!,
warehouse: ['Denver', 'Rotterdam', 'Singapore'][index % 3]!,
price: 49 + (index % 8) * 24,
stock: (index * 7) % 64,
leadTime: `${2 + (index % 5)} days`,
margin: `${18 + (index % 6) * 3}%`,
updated: `2026-05-${String(1 + (index % 17)).padStart(2, '0')}`,
}))
const model = localModel({ data: rows })
const scrollColumns = [
['category', 'Category'],
['owner', 'Owner'],
['region', 'Region'],
['supplier', 'Supplier'],
['warehouse', 'Warehouse'],
['price', 'Price'],
['stock', 'Stock'],
['leadTime', 'Lead time'],
['margin', 'Margin'],
['updated', 'Updated'],
] as const
export default function App() {
return (
<DataTable className="rounded-xl" computeRowKey={({ data }) => data.id} model={model} style={{ height: 340 }}>
<DataTableColumn field="name" sticky="left">
<DataTableColumnHeader className="min-w-[180px]">Product</DataTableColumnHeader>
<DataTableCell className="font-medium">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
{scrollColumns.map(([field, label]) => (
<DataTableColumn key={field} field={field}>
<DataTableColumnHeader className="min-w-[140px]">{label}</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
))}
<DataTableColumn field="status" sticky="right">
<DataTableColumnHeader className="min-w-[140px]">Status</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
</DataTable>
)
}The relevant lines, isolated:
<DataTableColumn field="name" sticky="left">
<DataTableColumnHeader className="min-w-[180px]">Product</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="status" sticky="right">
<DataTableColumnHeader className="min-w-[140px]">Status</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>Changing pinning at runtime
Section titled “Changing pinning at runtime”sticky on DataTableColumn sets the initial pinning. When the user needs to pin or unpin a column from a toolbar — say, a “Pin to left” menu item — publish to the table’s setColumnSticky$ channel instead of re-rendering with a new prop. The Sticky Columns example wires up that external toggle end-to-end; Controlling the Table covers the engine API the channel belongs to.