Formatting Cells and Headers
Most columns need formatting that goes beyond stringifying a field — currency values, status badges, stock-level colors, multi-line product names. DataTableCell and DataTableColumnHeader are where that formatting lives. Each one accepts a render function that the table calls when it paints a body cell or header; only cells inside (or close to) the viewport actually run, so the JSX stays a static, declarative description of the structure while the work scales to large tables.
Style the correct header surface
Section titled “Style the correct header surface”DataTableCell accepts a className prop alongside its render function. The class lands on the table’s own cell wrapper, so use it for alignment, font weight, text color, tabular-nums, and sticky-specific colors instead of wrapping the render output in another <div>.
<DataTableCell className="text-right tabular-nums">{({ cellValue }) => currency.format(Number(cellValue))}</DataTableCell>Headers have two surfaces because a grow column has both a base width and a rendered width. The table observes the intrinsic header to determine the base width, then assigns spare space to growing columns. If the observed element also stretches across that spare space, its rendered width becomes the next base measurement and the column cannot shrink when the table narrows. Keeping measurement and presentation on separate elements prevents that feedback loop.
className styles the intrinsic header content that the table measures. Use it for width, minimum width, padding, typography, and alignment within the base width. containerProps styles the outer container after the table assigns the rendered column width. Use it for paint and interaction that must cover the full column, such as a background, hover state, outline, box shadow, cursor, ARIA attribute, or event handler.
<DataTableColumnHeader
className="min-w-64"
containerProps={{
className: 'cursor-pointer hover:bg-muted/50',
}}
>
Product
</DataTableColumnHeader>The outer container’s geometry belongs to the table. Do not put width, flex, margin, padding, border, display, or justify-* utilities on containerProps; these can clip the measured content or move edge-aligned header slots. Keep those layout styles on className, use box-shadow or outline for full-width separators, and use HeaderEnd for a control that must stay at the rendered column edge.
Events attached through containerProps receive bubbled events from header slots and nested controls. When a header also contains sorting, resizing, reordering, links, or buttons, stop propagation in those controls or guard the container handler before running the whole-header action.
Keep width utilities such as w-* and min-w-* on DataTableColumnHeader.className. A w-full or flex-growth class on this intrinsic surface resolves only within the measurement boundary; it does not cover the rendered track. Use containerProps for that visual result. Do not put width utilities on DataTableCell. Use DataTableColumn grow={...} when a column should absorb leftover horizontal space. A cell width can make the body content fight the measured grid track and overlap neighboring columns. For cell content that needs truncation or nested layout, keep the cell unconstrained and put min-w-0 on an inner wrapper:
<DataTableColumn field="name">
<DataTableColumnHeader className="min-w-64">Name</DataTableColumnHeader>
<DataTableCell className="font-medium">{({ row }) => <div className="min-w-0 truncate">{row.data.name}</div>}</DataTableCell>
</DataTableColumn>Each visible cell is its own element; the table also column- and row-virtualizes, so an extra wrapper inside the render function multiplies by every rendered cell on every scroll. Save nested elements for styling that’s only part of the cell content — a status badge, an inline icon, a secondary line of metadata.
The shadcn wrapper accepts plain text for static headers:
<DataTableColumnHeader>Product</DataTableColumnHeader>And a render function for headers that depend on table state:
<DataTableColumnHeader>{({ columnState }) => (columnState.sticky ? 'Pinned product' : 'Product')}</DataTableColumnHeader>Cell values and rows
Section titled “Cell values and rows”cellValue is the selected field value or the result of the column accessor; row.data is the whole row. Reach for row.data whenever the cell depends on more than one field or renders UI that is not data-backed.
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { localModel } from '@virtuoso.dev/data-table'
const rows = [
{ id: 'SKU-001', name: 'Standing Desk', category: 'Office', price: 699, stock: 14 },
{ id: 'SKU-002', name: 'USB-C Dock', category: 'Peripherals', price: 229, stock: 42 },
{ id: 'SKU-003', name: 'Mechanical Keyboard', category: 'Peripherals', price: 169, stock: 28 },
]
const currency = new Intl.NumberFormat('en-US', {
currency: 'USD',
style: 'currency',
})
const model = localModel({ data: rows })
export default function App() {
return (
<DataTable className="rounded-xl" model={model} style={{ height: 280 }}>
<DataTableColumn field="name">
<DataTableColumnHeader>Product</DataTableColumnHeader>
<DataTableCell className="font-medium">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="price">
<DataTableColumnHeader className="justify-end">Price</DataTableColumnHeader>
<DataTableCell className="text-right tabular-nums">{({ cellValue }) => currency.format(Number(cellValue))}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="stock">
<DataTableColumnHeader>Status</DataTableColumnHeader>
<DataTableCell>
{({ row }) => (
<span className={row.data.stock < 20 ? 'font-medium text-amber-600' : 'text-foreground'}>
{row.data.stock < 20 ? 'Low stock' : `${row.data.stock} in stock`}
</span>
)}
</DataTableCell>
</DataTableColumn>
</DataTable>
)
}Render params
Section titled “Render params”The render function receives a single object whose fields surface what you’d otherwise have to look up by hand. cellValue is the shortcut for row.data[column.field] on field columns, the result of accessor(row.data) on computed columns, and undefined on id-only display columns. row.data is there for cells that depend on more than one field. column and columnKey identify which column this invocation is rendering — useful when one extracted renderer drives multiple columns. column.id is the stable public identity when you provided one; column.field exists only for columns that read a row-data field. columnState lets the renderer branch on runtime column state (currently just sticky, so headers and cells can style differently when the column is pinned). overlaidByScrollbar flips to true when a native OS scrollbar overlaps the cell’s edge — that’s the table telling you trailing UI in this cell is unreachable, so move buttons or actions inboard while the flag is set. Header params have the same shape minus cellValue and row — a header has no row to render against.
type CellRenderParams = {
cellValue: unknown // row.data[column.field], accessor(row.data), or undefined
row: RowItem // the virtualized row
column: ColumnDefinition
columnKey: string // internal key for this column instance
columnState: ColumnState // currently: { sticky }
overlaidByScrollbar: boolean // true when a native scrollbar overlaps the cell edge
}
type ColumnHeaderRenderParams = Omit<CellRenderParams, 'cellValue' | 'row'><DataTableColumnHeader>
{({ column, columnState }) => <span className={columnState.sticky ? 'font-semibold' : undefined}>{column.field}</span>}
</DataTableColumnHeader>Extracting renderers
Section titled “Extracting renderers”Inline render functions become unwieldy past a couple of lines, and they limit what you can do with the renderer itself. Pulling them out into named functions — or full components — buys three things at once: they appear by name in React DevTools (instead of as anonymous arrow functions), one renderer can drive several columns that share the same formatting, and you can wrap the renderer in React.memo so it skips re-renders when the row data hasn’t changed — useful for expensive cells like rich text or chart sparklines.
The render-param types are exported and double as the props type for the component:
import type { CellRenderParams, ColumnHeaderRenderParams } from '@virtuoso.dev/data-table'
function StockCell({ row }: CellRenderParams) {
return <span>{row.data.stock < 20 ? 'Low stock' : 'Healthy'}</span>
}
function FieldHeader({ column }: ColumnHeaderRenderParams) {
return <span>{column.field ?? column.id}</span>
}Extracted renderers are ordinary React components. They can call hooks, subscribe to React context, and compose with React.memo like any other component. One thing they don’t receive is the table’s context prop — that channel exists for table infrastructure (empty-state, sticky wrappers, scroll element) and doesn’t reach cell or header renderers. Close over screen-local state directly, or read it through React context if multiple columns need it.
Pass renderers as children of the cell or header:
<DataTableColumn field="stock">
<DataTableColumnHeader>{FieldHeader}</DataTableColumnHeader>
<DataTableCell>{StockCell}</DataTableCell>
</DataTableColumn>Header slots
Section titled “Header slots”A header can host additional UI alongside the label — sort buttons, filter menus, resize handles, drop indicators for reordering. Inline UI (buttons, menus) can go in the header’s render function. Two positions don’t fit there: a resize handle has to sit on the column boundary independent of label alignment, and a drop indicator has to span the entire header during a reorder drag. The table provides four named slots for these cases:
HeaderStart— before the label, in the header’s flex layout. Drag grips, leading icons.HeaderEnd— after the label, in the same flex layout. Sort buttons, menu triggers.HeaderEdge— pinned to the column boundary, outside label layout. Resize handles.HeaderOverlay— spans the whole header. Drop indicators, focus rings.
Declare slots inside the header; the table positions each one according to its slot type. Slot children receive the same render params as the header, so a sort button can read column.field to know which column it’s acting on.
<DataTableColumnHeader>
{() => 'Price'}
<HeaderEnd>{() => <button type="button">Sort</button>}</HeaderEnd>
</DataTableColumnHeader>When a header has any slots, the label must be a render function — plain string children are only valid for headers without slots. That’s why the example above writes {() => 'Price'} instead of just Price.
The shadcn wrapper’s resize and reorder UI is built on these slots. See Header Slots for the placement diagram, the component prop alternative to render-function children, and complete recipes for sort, filter, and menu slots.