Skip to content

Sticky Columns Example

A product column pinned to the left, a status column pinned to the right, and a dozen metric columns in between to force horizontal scrolling — the only situation where sticky columns matter. The toolbar above the table flips the status column’s sticky side at runtime through the engine.

  • sticky="left" / sticky="right" — declarative pin
  • setColumnSticky$ — runtime sticky changes from external UI
import { Button } from '@/components/ui/button'
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { localModel, columns$, setColumnSticky$, useEngineRef, useRemoteCellValue, useRemotePublisher } from '@virtuoso.dev/data-table'

import { metricFields, products } from './data'

import type { ColumnInfo } from '@virtuoso.dev/data-table'

function findColumnKey(columns: Map<string, ColumnInfo> | undefined, field: string) {
  return columns ? [...columns].find(([, info]) => info.field === field)?.[0] : undefined
}

const model = localModel({ data: products })

export default function App() {
  const engineRef = useEngineRef()
  const columns = useRemoteCellValue(columns$, engineRef)
  const setColumnSticky = useRemotePublisher(setColumnSticky$, engineRef)
  const statusKey = findColumnKey(columns, 'status')

  return (
    <div className="space-y-4">
      <div className="flex flex-wrap gap-2">
        <Button
          disabled={!statusKey}
          onClick={() => {
            if (statusKey) {
              setColumnSticky({ key: statusKey, sticky: 'left' })
            }
          }}
          variant="outline"
        >
          Pin status left
        </Button>
        <Button
          disabled={!statusKey}
          onClick={() => {
            if (statusKey) {
              setColumnSticky({ key: statusKey, sticky: 'right' })
            }
          }}
          variant="outline"
        >
          Pin status right
        </Button>
        <Button
          disabled={!statusKey}
          onClick={() => {
            if (statusKey) {
              setColumnSticky({ key: statusKey, sticky: undefined })
            }
          }}
          variant="outline"
        >
          Clear sticky
        </Button>
      </div>

      <DataTable className="rounded-xl" model={model} engineRef={engineRef} style={{ height: 400 }}>
        <DataTableColumn field="name" sticky="left">
          <DataTableColumnHeader className="min-w-[180px]">Product</DataTableColumnHeader>
          <DataTableCell className="font-medium">{({ row }) => row.data.name}</DataTableCell>
        </DataTableColumn>

        {metricFields.map((field) => (
          <DataTableColumn field={field} key={field}>
            <DataTableColumnHeader className="min-w-[140px]">{field}</DataTableColumnHeader>
            <DataTableCell className="tabular-nums">{({ cellValue }) => String(cellValue)}</DataTableCell>
          </DataTableColumn>
        ))}

        <DataTableColumn field="status" sticky="right">
          <DataTableColumnHeader className="min-w-[120px]">Status</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </DataTable>
    </div>
  )
}

Each pinned column takes width from the scrollable area. Past two or three sticky columns, horizontal scrolling stops being useful — you’ve spent your row width on pinned content and the remaining viewport can no longer fit anything meaningful.

The runtime toggle uses setColumnSticky$ against the same column key the table assigned during declaration. Look up the key by field with columns$ once at the call site instead of holding it in component state; the engine is the source of truth.