Skip to content

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.

Both DataTableCell and DataTableColumnHeader accept a className prop alongside their render function. The class lands on the table’s own per-cell wrapper element, so use it for any styling that covers the whole cell — alignment, font weight, text color, tabular-nums, sticky-specific colors — instead of wrapping the render output in another <div>.

<DataTableCell className="text-right tabular-nums">{({ cellValue }) => currency.format(Number(cellValue))}</DataTableCell>

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>

cellValue is the selected field; row.data is the whole row. Reach for row.data whenever the cell depends on more than one field.

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>
  )
}

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] and covers the common case; 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. 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]
  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>

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}</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>

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.