DataGrid

A virtualized, editable grid backed by a row store, with sorting, selection, and per-cell flashes.

import { useState } from "react"
import { createInstrumentFormatter, formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"

interface Inquiry {
  id: string
  client: string
  size: number
  price: number
}

const rows: Inquiry[] = [
  { id: "Q-1", client: "ALPHA", size: 5_000_000, price: 99.5 },
  { id: "Q-2", client: "BETA", size: 10_000_000, price: 99.515625 },
  { id: "Q-3", client: "GAMMA", size: 15_000_000, price: 99.53125 },
]
const note = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Inquiry>[] = [
  { key: "client", header: "Client", width: 140, accessor: (row) => row.client },
  { key: "size", header: "Size", width: 100, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
  { key: "price", header: "Price", width: 100, numeric: true, font: "mono", accessor: (row) => row.price, format: (value) => note.price(value as number) },
]

export default function DataGridDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: rows })
    return store
  })
  return <div className="h-40 w-fit max-w-full"><DataGrid store={store} columns={columns} preset="rfq" label="Open inquiries" /></div>
}

Installation

pnpm dlx shadcn@latest add tradecn/ui/data-grid#v2.0.0
npx shadcn@latest add tradecn/ui/data-grid#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/data-grid#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/data-grid#v2.0.0

Usage

Seed a stable store, define its columns, and give the grid an accessible name and a container with a height. Sizes below are notionals; prices print in 32nds.

import { useState } from "react"
import { createInstrumentFormatter, formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"

interface Inquiry {
  id: string
  client: string
  size: number
  price: number
}

const rows: Inquiry[] = [
  { id: "Q-1", client: "ALPHA", size: 5_000_000, price: 99.5 },
  { id: "Q-2", client: "BETA", size: 10_000_000, price: 99.515625 },
  { id: "Q-3", client: "GAMMA", size: 15_000_000, price: 99.53125 },
]
const note = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Inquiry>[] = [
  { key: "client", header: "Client", width: 140, accessor: (row) => row.client },
  { key: "size", header: "Size", width: 100, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
  { key: "price", header: "Price", width: 100, numeric: true, font: "mono", accessor: (row) => row.price, format: (value) => note.price(value as number) },
]

export default function DataGridDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: rows })
    return store
  })
  return <div className="h-40 w-fit max-w-full"><DataGrid store={store} columns={columns} preset="rfq" label="Open inquiries" /></div>
}

Controlled state

Control sort and columnState when your application needs to keep or restore the view. Click a header to sort; drag its edge to resize, or open its menu to move or hide a column. Client stays frozen on the left. Reset view restores both state values.

Choose left alignment in the preview toolbar to keep the grid's left edge fixed while resizing. The choice applies to previews across the site and is saved in this browser.

import { useState } from "react"
import { formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef, type ColumnState, type SortState } from "@/components/ui/data-grid"

interface Inquiry { id: string; client: string; side: "Buy" | "Sell"; size: number }

const columns: ColumnDef<Inquiry>[] = [
  { key: "client", header: "Client", width: 160, frozen: "left", sortable: true, accessor: (row) => row.client },
  { key: "side", header: "Side", width: 100, sortable: true, accessor: (row) => row.side, cell: ({ row }) => <span className={row.side === "Buy" ? "text-up" : "text-down"}>{row.side}</span> },
  { key: "size", header: "Size", width: 120, numeric: true, sortable: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
]
const initialColumns: ColumnState = { order: [], widths: {}, hidden: [] }
const initialSort: SortState = { key: "size", dir: "desc" }

export default function DataGridControlledDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: [
      { id: "Q-1", client: "ALPHA", side: "Buy", size: 5_000_000 },
      { id: "Q-2", client: "BETA", side: "Sell", size: 15_000_000 },
      { id: "Q-3", client: "GAMMA", side: "Buy", size: 10_000_000 },
    ] })
    return store
  })
  const [sort, setSort] = useState<SortState>(initialSort)
  const [columnState, setColumnState] = useState<ColumnState>(initialColumns)
  return (
    <>
      <div data-demo-controls className="flex flex-wrap items-center gap-3 text-xs lining-nums tabular-nums">
        <button type="button" className="rounded border px-2 py-1" onClick={() => { setSort(initialSort); setColumnState(initialColumns) }}>Reset view</button>
        <span>{sort ? `Sort: ${sort.key} ${sort.dir}` : "No sort"} · {columnState.hidden.length} hidden</span>
      </div>
      <div className="h-48 w-fit max-w-full">
        <DataGrid store={store} columns={columns} preset="rfq" label="Inquiries with controlled columns" sort={sort} onSortChange={setSort} columnState={columnState} onColumnStateChange={setColumnState} />
      </div>
    </>
  )
}

Shared defaults

Pass the same baseState and controlled columnState to the grid and ColumnChooser. Install ColumnChooser for this example; its installation includes DataGrid.

import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { ColumnChooser, ColumnChooserAnnouncer, ColumnChooserItem, ColumnChooserName, ColumnChooserResetAll, ColumnChooserVisibility, useColumnChooser } from "@/components/ui/column-chooser"
import { DataGrid, type ColumnDef, type ColumnState } from "@/components/ui/data-grid"

type Quote = { id: string; price: number; size: number }
const columns: ColumnDef<Quote>[] = [
  { key: "id", header: "Quote", width: 96, frozen: "left", accessor: row => row.id },
  { key: "price", header: "Price", width: 100, numeric: true, accessor: row => row.price, format: value => Number(value).toFixed(2) },
  { key: "size", header: "Size", width: 90, numeric: true, accessor: row => row.size },
]
const baseState: ColumnState = { order: ["id", "size", "price"], widths: { price: 144 }, hidden: ["size"] }

export default function DataGridSharedDefaultsDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Quote>({ getRowId: row => row.id })
    store.applyDeltas({ upsert: [
      { id: "Q-1", price: 100.25, size: 500 },
      { id: "Q-2", price: 99.75, size: 250 },
    ] })
    return store
  })
  const [columnState, setColumnState] = useState(baseState)
  const settings = { columns, columnState, onColumnStateChange: setColumnState, baseState }
  return (
    <div className="flex w-fit max-w-full flex-wrap items-start gap-4">
      {/* No reset-width control here, so the hint must not promise Delete. */}
      <ColumnChooser {...settings} labels={{ dragHint: "Up and Down move between columns, and Home and End jump to the ends. Drag a column, or hold Alt with an arrow key, to reorder; Alt+Home and Alt+End move to the edge. Space shows or hides a focused column. Frozen columns stay first." }} className="w-44 max-w-full shrink-0">
        <p className="font-medium">Visible columns</p>
        <ColumnChooserAnnouncer />
        <VisibleColumns />
        <ColumnChooserResetAll>Restore columns</ColumnChooserResetAll>
      </ColumnChooser>
      <div className="h-48 min-w-0 max-w-full">
        <DataGrid {...settings} store={store} label="Quotes with shared defaults" />
      </div>
    </div>
  )
}

function VisibleColumns() {
  const { rows, labels } = useColumnChooser()
  return (
    <>
      <p className="text-xs text-muted-foreground">{labels.dragHint}</p>
      <ul className="flex flex-col gap-1" aria-label="Visible columns">
      {rows.map(row => <li key={row.key}>
        <ColumnChooserItem columnKey={row.key}>
          <ColumnChooserVisibility />
          <ColumnChooserName />
        </ColumnChooserItem>
      </li>)}
      </ul>
    </>
  )
}

Uncontrolled defaults

Use baseState to initialize an uncontrolled grid and choose its reset defaults. Later changes apply on the next Reset columns action.

import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef, type ColumnState } from "@/components/ui/data-grid"

type Quote = { id: string; price: number; size: number }
const columns: ColumnDef<Quote>[] = [
  { key: "id", header: "Quote", width: 96, frozen: "left", accessor: row => row.id },
  { key: "price", header: "Price", width: 100, numeric: true, accessor: row => row.price, format: value => Number(value).toFixed(2) },
  { key: "size", header: "Size", width: 90, numeric: true, accessor: row => row.size },
]
const compact: ColumnState = { order: [], widths: { price: 144 }, hidden: ["size"] }
const expanded: ColumnState = { order: ["id", "size", "price"], widths: { price: 120 }, hidden: [] }

export default function DataGridDefaultsDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Quote>({ getRowId: row => row.id })
    store.applyDeltas({ upsert: [
      { id: "Q-1", price: 100.25, size: 500 },
      { id: "Q-2", price: 99.75, size: 250 },
    ] })
    return store
  })
  const [expandedDefaults, setExpandedDefaults] = useState(false)
  return (
    <>
      <div data-demo-controls className="flex max-w-sm flex-col gap-2 text-xs">
        <label className="flex items-center gap-2">
          <input type="checkbox" checked={expandedDefaults} onChange={event => setExpandedDefaults(event.target.checked)} />
          Show size on reset
        </label>
        <p className="text-muted-foreground">Change the defaults, then choose Reset columns from a column menu.</p>
      </div>
      <div className="h-40 w-fit max-w-full">
        <DataGrid store={store} columns={columns} baseState={expandedDefaults ? expanded : compact} label="Quotes with local settings" />
      </div>
    </>
  )
}

Selection and actions

Set selectionMode="multi" to extend the RFQ preset's single selection. Use the checkboxes, Shift for a range, or Command/Ctrl to toggle rows. A selected row is marked by a bar at the start of its first cell, the selection box's when there is one, rather than filled, so its up and down text keeps its contrast. In a grid that scrolls sideways, freeze the first column or show the selection box, so the bar stays in view; Watchlist, Positions, RfqStack, AuditTrail, and AlertHistory freeze theirs. Right-click plain content in a selected row, or press Shift+F10 with the grid focused, to act on the selection; an unselected row targets just itself.

Enter on the grid or a double-click on plain cell content activates a row. The Inspect button acts on its own row and preserves selection. This example prints the requested action below the grid; your handler opens a ticket or sends a command.

import { useMemo, useState } from "react"
import { ContextMenuItem } from "@/components/ui/context-menu"
import { formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"

interface Inquiry { id: string; client: string; size: number }

const inquiryColumns: ColumnDef<Inquiry>[] = [
  { key: "client", header: "Client", width: 160, accessor: (row) => row.client },
  { key: "size", header: "Size", width: 120, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
]

export default function DataGridSelectionDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: [
      { id: "Q-1", client: "ALPHA", size: 5_000_000 },
      { id: "Q-2", client: "BETA", size: 10_000_000 },
      { id: "Q-3", client: "GAMMA", size: 15_000_000 },
    ] })
    return store
  })
  const [selection, setSelection] = useState<ReadonlySet<string>>(new Set())
  const [action, setAction] = useState("No action yet.")
  const columns = useMemo<ColumnDef<Inquiry>[]>(() => [...inquiryColumns, {
    key: "inspect", header: "Action", width: 100, accessor: () => null,
    cell: ({ row }) => <button type="button" className="rounded border border-border px-2 focus-visible:outline-2 focus-visible:outline-ring" aria-label={`Inspect ${row.client}`} onClick={() => setAction(`Inspect: ${row.client}`)}>Inspect</button>,
  }], [])
  return (
    <div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
      <div className="h-48">
        <DataGrid
          store={store} columns={columns} preset="rfq" label="Select inquiries"
          selectionMode="multi" selectionColumn selection={selection} onSelectionChange={setSelection}
          onRowActivate={(row) => setAction(`Open ticket: ${row.client}`)}
          renderContextMenu={(rows) => (
            <>
              <ContextMenuItem onClick={() => setAction(`Quote: ${rows.map((row) => row.client).join(", ")}`)}>Quote inquiries</ContextMenuItem>
              <ContextMenuItem onClick={() => setAction(`Pass: ${rows.map((row) => row.client).join(", ")}`)}>Pass inquiries</ContextMenuItem>
            </>
          )}
        />
      </div>
      <p>{selection.size} selected</p>
      <p role="status">{action}</p>
    </div>
  )
}

Totals for the view

Each footer function receives all rows in the filtered view, including rows outside the viewport. Filter out the smallest inquiry to see the count fall from three to two and the size total from 30mm to 25mm. Keep the footer functions and filter stable between renders.

import { useState } from "react"
import { formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"

interface Inquiry { id: string; client: string; size: number }

const columns: ColumnDef<Inquiry>[] = [
  { key: "client", header: "Client", width: 160, accessor: (row) => row.client },
  { key: "size", header: "Size", width: 120, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
]
const largeInquiry = (row: Inquiry) => row.size >= 10_000_000
const footer = {
  client: (rows: Inquiry[]) => `${rows.length} inquiries`,
  size: (rows: Inquiry[]) => formatNotional(rows.reduce((total, row) => total + row.size, 0), { unit: "mm" }),
}

export default function DataGridTotalsDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: [
      { id: "Q-1", client: "ALPHA", size: 5_000_000 },
      { id: "Q-2", client: "BETA", size: 10_000_000 },
      { id: "Q-3", client: "GAMMA", size: 15_000_000 },
    ] })
    return store
  })
  const [largeOnly, setLargeOnly] = useState(false)
  return (
    <>
      <div data-demo-controls className="text-xs lining-nums tabular-nums">
        <label className="flex items-center gap-2"><input type="checkbox" checked={largeOnly} onChange={(event) => setLargeOnly(event.target.checked)} />Only sizes of at least 10mm</label>
      </div>
      <div className="h-48 w-fit max-w-full">
        <DataGrid store={store} columns={columns} preset="rfq" label="Inquiry totals" filter={largeOnly ? largeInquiry : undefined} footer={footer} />
      </div>
    </>
  )
}

Store updates

Pass feed batches to store.applyDeltas: patch changes existing rows, remove drops expired inquiries, and upsert adds arrivals. Receive a batch below to step through these changes with twelve rows. The button supplies sample feed data; no clock or connection runs in the background. Prices move by one tick, with the signed change beside them.

Scroll down before receiving a batch to see the RFQ preset keep the first visible row in place when an earlier row leaves. A feed that already batches can call applyDeltas directly; use createFrameBatcher when messages arrive individually, and cancel it when the feed disconnects.

import { useRef, useState } from "react"
import { createInstrumentFormatter, formatSigned } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"

interface Inquiry { id: string; price: number; change: number }

const note = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Inquiry>[] = [
  { key: "id", header: "Inquiry", width: 120, frozen: "left", accessor: (row) => row.id },
  { key: "price", header: "Price", width: 100, numeric: true, font: "mono", accessor: (row) => row.price, format: (value) => note.price(value as number) },
  { key: "change", header: "Change", width: 100, numeric: true, accessor: (row) => row.change, format: (value) => formatSigned(value as number, { decimals: 6 }) },
]

export default function DataGridUpdatesDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Inquiry>({ getRowId: (row) => row.id, lane: "ordered" })
    store.applyDeltas({ upsert: Array.from({ length: 12 }, (_, i) => ({ id: `Q-${i + 1}`, price: 99.5, change: 0 })) })
    return store
  })
  const nextId = useRef(13)
  const [received, setReceived] = useState(0)
  const receive = () => {
    const ids = store.getIds()
    const patches = ids.slice(1).map((id, i) => {
      const row = store.getRow(id)!
      const change = (i % 2 === 0 ? 1 : -1) / 64
      return { id, fields: { price: row.price + change, change } }
    })
    // A feed supplies one batch: changed quotes, an expired inquiry, and a new arrival.
    store.applyDeltas({ patch: patches, remove: [ids[0]!], upsert: [{ id: `Q-${nextId.current++}`, price: 99.5, change: 0 }] })
    setReceived((count) => count + 1)
  }
  return (
    <>
      <div data-demo-controls className="flex items-center gap-3 text-xs lining-nums tabular-nums">
        <button type="button" className="rounded border px-2 py-1" onClick={receive}>Receive a batch</button>
        <span>Batches received: {received}</span>
      </div>
      <div className="h-56 w-fit max-w-full">
        <DataGrid store={store} columns={columns} preset="rfq" label="Updating inquiries" />
      </div>
    </>
  )
}

API Reference

Props

T is your row type; RowId is a string. Give the grid a container with a height. Keep unchanged columns, filter, sort, and rule lists stable between renders to avoid rebuilding the grid's view.

Prop Type Default Purpose
store RowStore<T> Required Rows from row-store.
columns ColumnDef<T>[] Required Column definitions; see below.
label string Required Accessible name of the grid.
preset DataGridPreset "blotter" Defaults from the preset table.
view RowView<T> Internally owned view Supply your own membership, order, and reorder hold.
filter (row: T) => boolean None Filter the internally owned view.
rules ReadGridRules None Cell and row tones, filters, and sort rules, written or read with readRules.
rowHeight number Preset Fixed row height in px.
overscan number 8 Extra rows rendered beyond the viewport.
rowEnter Partial<RowEnterBehavior> Preset Override highlight, pinViewport, or followTail booleans.
reorderHoldMs number Preset Delay in ms before the internally owned view reorders after interaction.
announceRowCount "off" | "debounced" Preset Enable the polite row-count announcement, cleared 5 seconds after it is said.
selectionMode "none" | "single" | "multi" Preset Selection behavior.
selectionColumn boolean false Show checkboxes in multi-select mode. They take a pointer, not Tab: they add no Tab stops, and Space on the grid toggles the focused row. The header menu buttons keep theirs.
flashWindowMs number 900 Cell flash duration in ms. A value that isn't a finite length of zero or more uses the default.
footer Record<string, (rows: T[]) => string> None Totals keyed by column key.
onEdit (change: EditChange<T>) => void | Promise<unknown> None Handle commits; enables columns with edit.
onRowActivate (row: T, id: RowId) => void None Handle grid Enter or an unhandled double-click on plain row content, unless it opens an editor.
renderContextMenu (rows: T[], ids: RowId[], target?: RowId | null) => ReactNode None Menu items opened from an owned row, for the selection or targeted row. target is the row the menu opened on, which can sit inside a larger selection, taken from the event that opened it.
getRowProps (row: T, id: RowId) => RowDecoration | undefined None Row classes, state, tone, and accessible description. The class lands on the row's frozen cells too, over their opaque background, so keep it to colors and marks.
getRowLabel ((row: T, id: RowId) => string) | null None A row's name that holds while its cells tick, such as its symbol. null is the same as none. Keep its identity stable.
emptyState ReactNode "No rows" Empty-view content.
labels Partial<DataGridLabels> DEFAULT_DATA_GRID_LABELS The grid's own words; see Labels.
className string None Classes on the grid root.
initialRect { width: number; height: number } None Viewport size in px before measurement, for tests or server rendering.

Each context-menu opening remounts its content and supplies a fresh snapshot of target rows, including reopening during a closing animation. Ordinary rerenders within an opening preserve content state. Subscribe inside custom content for values that must update while it stays open. ids is the raw target set while rows holds only the targets still in the store, so after a removal the arrays differ in length and do not line up by index: act on ids, and read each row through the store when you need its fields.

RowDecoration accepts optional string fields: className, data-state, data-rule, data-tone, and aria-description. A row's description is yours, then a row rule's words, joined, so neither hides the other.

State and callbacks

Omit a state prop to let the grid manage it. Pass it to control that state, and apply changes from its callback. Callbacks also work with internal state.

Prop Type Default Purpose
columnState ColumnState Initial baseState Complete snapshot of order, width overrides in px, and hidden keys.
onColumnStateChange (state: ColumnState) => void None Receive column changes and resets.
baseState ColumnState { order: [], widths: {}, hidden: [] } Initial uncontrolled settings and the latest reset target.
sort SortState null { key: string, dir: "asc" | "desc" } or no header sort.
onSortChange (sort: SortState) => void None Receive header sort changes.
selection ReadonlySet<RowId> Empty set Selected rows.
onSelectionChange (selection: ReadonlySet<RowId>) => void None Receive selection changes.
focusedRowId RowId | null null Focused row.
onFocusedRowChange (id: RowId | null) => void None Receive row focus changes.

The grid initializes uncontrolled columns from baseState once. Replacing it later leaves current settings untouched until Reset columns is chosen.

Controlled columnState always supplies the complete snapshot; defaults are never merged into it. Initialize controlled state from your defaults or a saved snapshot.

Reset emits the configured baseState through onColumnStateChange, including when it already matches. It preserves sort, selection and row focus. The grid applies its usual column ordering, visibility and minimum widths without normalizing the emitted snapshot.

ColumnChooser normalizes its edits and suppresses effective no-ops; share defaults for the same rendered columns, not identical callback payloads or counts.

Keep an external chooser or application reset control when a snapshot can hide every column: an empty header has no menu to reopen columns.

A move from the header or Alt+arrows rebuilds the order from visible columns, so a hidden column loses its place and returns at the end when shown; ColumnChooser keeps hidden columns in place when the same state matters. A move across the frozen boundary changes nothing, and the header menu disables it.

Drag a column's right edge to resize it. Movement is measured from its starting width, rounded to pixels, and clamped to minWidth. Other column settings and the change callback come from the latest committed props.

Resizing starts on an unhandled primary-button press from a primary pointer. A press already prevented by an application handler does not start a drag.

Releasing or canceling the pointer, losing pointer capture or window focus, or removing the column ends the drag. Accepted widths remain in place. The edge handle is pointer-only; focus the grid, choose a column with Left or Right, then use Alt+Shift+Left / Right to resize it by 8 px.

Columns

Each ColumnDef<T> describes one column. Frozen columns stay on the left, before the other columns regardless of columnState.order.

Field Type Default Purpose
key string Required Column identity for state, rules, and edits.
header ReactNode Required Header content.
title string The header, when it is a nonblank string The column's name in words: the header's name to a screen reader, its menu button and resize handle, the editor, the column chooser, and the CSV header. Without one, a header of markup or a blank one is named by the key, as menus and the CSV are.
width number Required Initial width in px.
accessor (row: T) => unknown Required Value used for display, sorting, rules, and flash direction.
minWidth number 48 Minimum width in px.
align "left" | "right" | "center" Right if numeric, otherwise left Text alignment.
frozen "left" None Keep the column visible during horizontal scrolling.
sortable boolean false Enable header sort controls.
hidden boolean false Hide the column regardless of column state.
format (value: unknown, row: T) => string String(value); nullish values use NULL_TOKEN Display text. Carries into rule descriptions on a column that also has parse, when it reads just the value.
cell (ctx: { row: T; value: unknown; rowId: RowId; edit?: CellEditHandle }) => ReactNode Formatted text Custom content; flashes still follow the accessor.
numeric boolean false Numeric typography, alignment, and default flashing.
font "numeric" | "mono" "numeric" Font family for numeric cells.
flash false | "fill" | "ring" Preset for numeric columns; otherwise false Override or disable cell flashes.
parse (text: string) => unknown Number if numeric; otherwise text Read values in grid rules. Separate from edit.parse.
edit CellEdit<T> None Editing behavior; also requires the grid's onEdit.

Numeric cells carry data-numeric and use lining, tabular figures in --tradecn-font-numeric. Use font: "mono" for fraction quotes such as 99-16+, giving digits and dashes equal widths; quotes with and without a tail still differ in length, so a right-aligned column lines tails up only against padding you add. typography explains the choice; numericFontClass in format selects it from an instrument convention.

Presets

Preset Height (px) Selection Flash Hold (ms) Highlight arrivals Viewport Announce count
blotter 24 Multi Fill 750 Yes Pin Yes
watchlist 22 Single Fill 0 No Unpinned No
rfq 26 Single Ring 1,000 Yes Pin Yes
option-chain 20 None Ring 0 No Unpinned No
tape 22 Single Fill 0 Yes Follow tail Yes
parameters 24 Single Ring 0 No Pin No

Override behavior with the corresponding props and each column's flash; override the presets' text-xs size through className. watchlist, blotter, and parameter-grid build on these presets.

How it stays inside the frame

Rows subscribe individually through useRow. A value update that leaves the view's membership and order unchanged re-renders the affected visible row without re-rendering the other rows. React batches notifications from one applyDeltas call. Sorting, filtering, and footer totals can also do work for that batch.

TanStack Virtual positions fixed-height rows. The focused row and the row being edited render even outside the window, so whenever the grid has a height aria-activedescendant names an element that exists, and Shift+F10 brings the focused row into view and opens its menu there. With pinViewport, arrivals or removals adjust scrollTop by the first visible row's change in index times rowHeight, provided that row remains in the view.

Flash memory is keyed by row and column. A cell returning with the same value resumes a flash only while its window remains open. Reduced motion uses a static mark instead of animation. The install adds the up, down, and flat tokens, and the soft variants of those three and of stale and expiring, if absent; flashes use the first three, and rules tint with all five soft variants.

The reorder hold

Key events delivered to the grid from its own element or contained controls call view.touch(), even when already handled. That includes a cell editor's keys, so a batch can't re-sort the row being typed into while the hold lasts. Nested grids and portaled content outside the grid are excluded.

Pointer presses in the scroll area call touch() during capture, including presses on controls and editors. This keeps rows steady while a control handles its press. Nested grids and content portaled outside the grid have their own interaction ownership.

During the hold, existing rows keep their relative order, new rows append, and removed or filtered-out rows leave. The view sorts again when the hold expires, even without another feed update.

A row the view held before, or one the store held when the view last changed, doesn't arrive: a row a filter lets back in, or one that comes back into the view by any other road, neither flashes nor counts in the row count's new. A row the store took in while a filter hid it counts as an arrival when the filter lets it in. An arrival the hold parks starts its highlight window when the hold lapses, not when it arrived — and no later hold revives it. A parked row that is already rendered, at a visible tail or in the overscan band, waits with the others and flashes in place on the grid's deadline timer when the hold lapses, whether or not the release publishes an update. One scrolled to later flashes whatever remains of its window. The grid's own keys and presses carry extensions to still-parked marks; calling touch() on a supplied view directly shortens those remainders instead of restarting them. This release accounting needs the view's holdExpiresAt, which the internally owned view and row-store views provide; on a custom view without it, a hold longer than the highlight window swallows the parked flash.

The reorderHoldMs prop configures the internally owned view. With a supplied view, configure its hold yourself; the grid still calls touch() on it.

Following the tail

Use tape for append-only feeds, or set rowEnter={{ followTail: true, pinViewport: false }} on another preset. Row-entry overrides merge with the preset, so turn pinning off explicitly when switching from it.

The viewport moves to the end when following is enabled and as rows arrive. Interactions that touch this grid's reorder hold, or scrolling away, pause following. An N new button counts the rows in view that the store didn't hold when following paused, so rows that left, or rows a filter lets back in, never count; click it or scroll to the end to resume.

footer={{ size: (rows) => formatQuantity(sum(rows)) }} adds a sticky totals row. Each function receives all rows in the filtered, ordered view, including rows outside the viewport. Only displayed columns with a footer function get a value.

Totals calculate on mount and subscribe to store batches. Changes to the view's ids, resolved columns, footer object, or store also recompute them. Keep footer stable between renders. Totals have no per-frame timer or flash and retain the column's numeric typography and alignment.

Rules as data

grid-rules, installed alongside the grid, supplies GridRules: plain data for tones, filtering, and ordering.

Rule Internally owned view Supplied view
rules.columns Color cells and rows Color cells and rows
rules.filter Every rule must pass, along with filter Ignored, as is filter
rules.sort Break ties after the header sort; provide the order when no header sort is set Ignored; the view owns the order

A supplied view also ignores sort for ordering. Header controls can still report changes through onSortChange for you to apply.

String rule values use the column's parse when provided, for example parse: (text) => parsePrice(text, convention), and a parsed decimal threshold snaps to the convention's grid, as a cell editor that parses with parsePrice does. The column's format carries into rule descriptions only when it reads just the value — descriptions call it without a row, so a row-reading format keeps the typed words while the threshold still snaps. Numeric and boolean values bypass it. Matched cells and rows carry data-rule, data-tone, and an accessible description. getRowProps takes precedence over row decorations; a cell's rejection message takes precedence over its rule description.

Editing in place

Give the grid onEdit and the column a CellEdit<T>:

Field Type Default Purpose
parse (text: string, row: T) => unknown Required Return a value or editProblem("…").
format (value: unknown, row: T) => string Column formatter or String(value); blank for nullish values Editor and pending text.
validate (value: unknown, row: T, commit?: EditCommit) => EditProblem | null | undefined None Return a problem to refuse the value. The grid passes commit, { via, repeat, session }: via is "enter", "tab", "blur", or "value" for a toggle or a cell control's commit; repeat is true for the repeats of a held Enter or Tab in an editor, and for a cell control's commit that says so, and false for a blur and for a toggle, whose held Space, Enter, or F2 commits once; session numbers each opening of an editor, uniquely across every grid on the page, and value commits share 0; pending(key) gives the value another cell of the row has sent and not yet had back, by column key, or undefined, so a check reads the row as it shows. A check that asks a question can insist on a fresh Enter in the same opening for the answer.
step (value: unknown, dir: 1 | -1, big: boolean, row: T) => unknown None Return the next value for Up or Down; Shift sets big.
toggle (value: unknown, row: T) => unknown None Return a value to commit without opening an editor.
canEdit (row: T) => boolean Returns true Make individual cells read-only with false.

Use big to implement a larger step, such as ten ticks with Shift; the grid does not multiply it. A cell refused by canEdit carries aria-readonly="true". An editor whose cell canEdit refuses after it opened keeps the typed text: Enter says labels.notEditable over the cell instead of closing it, and sends once the cell takes edits again; Escape discards the text. The editor opens a phone's text keyboard, since the decimal pad has no minus for a value below zero. parameter-grid builds a parameter sheet on this API.

Only one text editor opens at a time. A failed parse or validation leaves it open with its problem in words over the cell, the editor's description, and a destructive ring; leaving the editor instead discards invalid input and commits valid changes. An editor opened and left unchanged sends nothing, whatever rounding its format applies, and every close of an untouched editor — Enter, Tab, blur, Escape, or the teardowns below — restores the promise-backed pending or rejected state it covered, with a promise that settled meanwhile applied to it. A pending from a void onEdit has no promise to settle it, so reopening dismisses it the way v1 did. A value equal to the store's current value sends nothing.

Hiding or removing a column while its text editor is still open discards the draft without calling onEdit. A row that a filter or removal takes out of the view does the same: its open editor closes, the draft is discarded, and the returning row does not reopen it or take focus. A re-sort that only moves the row keeps the editor open with its draft. The row being edited stays rendered wherever the viewport goes, so an editor scrolled or re-sorted out of the window keeps its input and its focus. Removing its edit configuration or switching to toggle also discards it.

Restoring the column does not reopen the draft. These column changes preserve pending and rejected edits.

A commit calls onEdit with { rowId, key, value, previous, row }. The grid never writes the store. Its default renderer shows the committed text muted, in italics with a dotted underline that holds under a rule's tint, with data-pending and a description that says Pending (labels.pending), until the store value matches it or the returned promise resolves. Resolution clears pending state and displays the current store value, which may still be the old value. Returning nothing leaves the edit pending until the store matches.

A thrown error or rejection of a still-pending promise displays the store value with the error message, data-rejected, and destructive styling, except inside a rule's tint, where its text is the foreground. Pending state matches replies by value, not by request, even with promises: commit 105, then 106, then 105 again, and the first request's success settles the third's pending mark, whose own later rejection is dropped. Returning the promise from onEdit lets a rejection reach the cell, but the grid cannot tell two requests for the same value apart — keep one request in flight per cell by refusing the cell through canEdit while a reply is pending, as ParameterGrid's server demo does by withdrawing the permission. A custom cell that merely disables its control blocks the pointer only: on a toggle column, the grid's Space, Enter, and F2 still commit. Reopening the editor clears the error while it is open; closing it untouched brings the error back, and committing a change replaces it. A pending cell can also be reopened, starting from its committed text.

A custom cell receives edit: { status, commit(value, how?), open() } when editing is enabled for its column. It sees status as absent or an object whose kind is pending or rejected. While a text editor is open, the grid renders its built-in editor instead of calling cell. The renderer chooses its content and can disable its control while pending; commit(value) validates and sends the value without parsing text. Pass { repeat: true } as how for a commit a held key repeats, so validate sees it as one.

For an available text-editable cell, open() opens and focuses the text editor and scrolls its row and its column into view. It does nothing for a row outside the view, and on the cell already being edited it keeps the draft and asks for focus again. It does not select the row or change the grid's logical row or chosen column. To return grid navigation to that row after editing, control focusedRowId and update it in the action handler before calling open(). Column shortcuts still use the previously chosen column; choose a column with grid navigation or its header.

Identity

Selection, focus, and context-menu targets use row ids, so a reorder preserves their identity. The grid never prunes selection or a menu's targets: a removed or filtered-out row stays selected and counted until your application drops it, and a Shift range whose anchor has left the view starts from the top of the view. Prune selection as rows retire, as the watchlist layout demo does against the symbols its remove handler receives. aria-activedescendant names the focused row while its id is in the view; each data row's aria-rowindex is its zero-based view index plus two, accounting for the header.

With announcements enabled, a 1,000 ms timer reports the row count through a polite live region, for example "1,024 rows, 12 new". It starts on mount and restarts when the count changes. Continuous count changes delay the announcement.

Without getRowLabel, or with null, a row is named by its data cells, never its selection box, so a screen reader can read the focused row again at every update. With it, each row is named by what it returns, and the grid reads the focused row's cells once through a polite live region of its own, 400 ms after focus rests on the row: on a new row, or when the grid takes focus. A cell that changes never reads the row, and an empty name leaves the row named by its cells, still read. The reading clears 5 seconds after it is said and when focus leaves the grid, so a reader browsing the rows later meets no stale words. Return a name that tells rows apart, and keep the function's identity stable, since a new one re-renders every row.

The reading is each column's cell in order, joined with commas, and approximates the accessible-name rules for content: a text field's value and a select's chosen option, labelled or not and whatever their role, so an <input role="combobox"> reads its value; on an element that takes a name, the targets of aria-labelledby, each read whole, a hidden target and hidden content inside a target included, or else its aria-label; an image's alt; otherwise the text, with elements side by side read as separate words. An element takes a name when the first word of its role is anything but generic, presentation, none, caption, code, deletion, emphasis, insertion, paragraph, strong, subscript, superscript, or time, or, with no role, when it is an a, area, button, iframe, img, input, meter, output, progress, select, textarea, or svg element; any other element but an image, a span, a heading, or a table included, is read by its content. So any other labelled control, such as a slider or a combobox built on a button, reads its label rather than its value. Outside those targets it leaves out content marked aria-hidden or hidden, and it leaves out the selection checkbox. It reads content hidden only by CSS, such as display: none or visibility: hidden, which a screen reader skips, so mark such content aria-hidden or hidden as well. It doesn't read content inside a shadow root, a title attribute, a cell's rule description, a control's state, or the column names.

Pointer interactions

A primary-button press on plain cell content requests row focus and selection. Other buttons request focus only. selectionMode="none" leaves selection unchanged, and controlled state changes only when your callback applies the request.

In multi-select mode, Shift adds a range from the current view without clearing the existing selection. Changes made in capture or focus callbacks are included. Ctrl or Cmd toggles a row.

A double-click opens an editable text cell or activates the row.

Buttons, links, inputs, editors, focusable content and interactive ARIA widgets keep their own pointer behavior. Their presses and double-clicks do not implicitly select, focus, edit or activate the row. Selection checkboxes and custom edit.commit controls still run their explicit actions.

Fallback ARIA role lists use the first recognized role: role="unsupported button" owns its interaction, while role="status button" remains plain content.

Use native controls with accessible names in custom cells. A custom handler can also claim a press or double-click with preventDefault() or stopPropagation(). Row actions run during bubbling, after the child handler. To claim one from a parent, use its capture handler.

With renderContextMenu, right-clicking plain content in a selected row keeps the selection; another row becomes the target. Either way the row under the pointer is the menu's target, even while a parent that controls focusedRowId has not moved focus there; a touch's long press opens it on the touched row, and Shift+F10 or the Menu key on the focused row, the menu opening at the focused cell, which it first scrolls into view, or 8 px into the row's visible left edge before a column is chosen. Controls, nested grids, content portaled outside this grid, headers, footers and empty space do not open its row menu. Controls retain their own context menus, including the browser's default when the application leaves it available.

When a row menu is installed, rejected contextmenu, non-mouse pointerdown, and single-touch touchstart events stop React bubbling after child handlers run. Use capture handlers on ancestors to observe them. Multiple-touch touchstart events reach the menu so it can cancel a pending long press.

Rejected pointer and touch starts retain native propagation for dialog dismissal and touch tracking. Rejected context-menu events from contained targets also stop native bubbling, including later listeners on a hydrated document root. Events from content portaled outside the grid retain native delivery.

A document-level bubbling handler cannot suppress the browser menu for those contained targets. Call preventDefault() in the custom control's onContextMenu handler to suppress it there. A capture handler that prevents every context-menu event also claims the grid's row menus.

Open shadow roots use the browser's event path. Closed roots expose only their hosts. Set data-grid-interaction on the host or a containing element outside that root:

Value Row actions Reorder hold and tail following
control Skip row actions and row menu. Retain hold and pause on presses and keys.
independent Skip row actions and row menu. Skip hold and pause. Use for a separate widget.

Markers add no roles or tab stops. Child handlers still run. Custom content owns accessibility, focus and styles.

Mark interactive closed-root content explicitly. It can otherwise trigger row actions. Plain text in an open shadow root still selects or activates its row.

CSS such as pointer-events: none can send a disabled control's press to the cell beneath it. Wrap that control to keep those interactions within the custom content:

import type { ReactNode } from "react"

export function CellControl({ children }: { children: ReactNode }) {
  return <span className="inline-flex leading-normal" data-grid-interaction="control">{children}</span>
}

export function DisabledCellAction() {
  return <CellControl><button type="button" disabled className="pointer-events-none">Inspect</button></CellControl>
}

Keyboard

These DataGrid shortcuts run with focus on the grid itself. Header controls, selection checkboxes, and custom cell controls keep their own key behavior.

After using a control, press Shift+Tab until the grid itself has focus, or click a cell without a control, to resume grid navigation.

To handle a grid shortcut in a parent, call preventDefault() from onKeyDownCapture. A parent's bubbling onKeyDown runs after the grid. The grid skips commands for events already prevented.

Key Action
Up / Down Move row focus, bringing the focused column back into view sideways.
PageUp / PageDown Move row focus by a viewport, bringing the focused column back into view.
Home / End Focus the first / last row, bringing the focused column back into view.
Shift + a row navigation key Extend selection in multi-select mode.
Left / Right Move column focus, scrolling the column into view sideways, clear of the frozen columns.
Space Toggle selection in multi mode; select in single mode. On an editable toggle cell, commit its toggle instead. A held Space acts once, as a checkbox's does. Does nothing while the focused row is outside the view.
Enter Edit the focused editable cell, including toggles, once per press; otherwise activate the row, once per press. An editor opens, and a toggle commits, with its column scrolled into view. Does nothing while the focused row is outside the view.
F2 Edit the focused editable cell, including toggles, once per press. An editor opens, and a toggle commits, with its column scrolled into view. Does nothing while the focused row is outside the view.
Type a character other than Space Open an editable text cell with that character, once per press; the editor takes the repeats. Toggle cells and Ctrl, Cmd, or Alt combinations do not open an editor, and nothing opens while the focused row is outside the view.
Escape Clear selection, once per press.
Ctrl or Cmd+A Select all rows in the view in multi mode, once per press.
Alt+Left / Right Move the focused column, keeping it in view, even when a controlled columnState takes the move later. Does nothing past either end or across the frozen columns, which lead whatever the order says.
Alt+Shift+Left / Right Resize the focused column by 8 px, keeping it in view. Alt+Shift+Left does nothing at the column's minimum width.
Alt+S Cycle a sortable column: ascending, descending, off, one step per press. Option+S on a Mac, by the key's place, as Alt+H is.
Alt+H Hide the focused column, once per press.
Shift+F10 / Menu Open the context menu on the focused row, once per press: at the focused cell, scrolled into view, or 8 px into the row's visible left edge before a column is chosen.

Row navigation also selects the focused row in single-select mode. A row focus moves to, by the keys, a parent's focusedRowId, or a hand-off, comes into view, and a row already in view stays where it is. When the focused row leaves the view, focus goes to the row now at its place, or to the last row, and the keys go on from there; focus set on a row that hasn't arrived yet waits for it, and an open row menu keeps the row it opened on; a parent that controls focusedRowId is asked once, and while it keeps the departed row, the keys that act on a row do nothing. Each column header has move, hide, and reset controls, plus a resize handle. Sort controls appear only when the column has sortable: true. Move left and Move right are disabled at either end and beside the frozen columns, and a move brings the column into view, as Alt+Left and Right do.

When a column disappears while its menu trigger or menu owns focus, focus returns to the grid. A surviving menu trigger retains the menu's normal close-focus behavior.

If a column disappears or no longer supports a text editor, its focused editor returns focus to the grid when removed. Focus moved elsewhere is left alone.

Hiding or removing the focused column clears column focus. Press Left or Right on the grid to choose a visible column before using its shortcuts. The chosen column is shown visually and is not reported to assistive technology, which hears the row as one unit: a screen-reader workflow drives column actions from the header controls, not the grid shortcuts. The header menu carries move, hide, sort, and reset but no resize — Alt+Shift+arrows is the only keyboard resize, and it relies on that unannounced column focus.

Existing sort, row focus, and selection remain unchanged. A controlled hide request leaves column focus in place until the caller accepts it.

Inside a text editor:

Key Action
Enter Commit and return focus to the grid.
Escape Discard the edit and return focus to the grid.
Tab / Shift+Tab Commit and open the next / previous editable text cell in the row, skipping toggles. At either end, return focus to the grid.
Up / Down Call step with direction 1 / -1. Shift sets big: true.
Ctrl, Cmd, or Alt + Up / Down Leave the event to listeners above the grid, such as a hotkey registry.

Opening an editor selects its text unless you opened it by typing a character.

Labels

labels merges partial overrides into DEFAULT_DATA_GRID_LABELS. A template fills {name} with a column's title and {n} and {m} with counts in the reader's locale; a label that holds a count also takes a function of the count, for a language whose plurals a template can't hold.

Label Default Where
selectRow Select row A row's selection box
selection Selection The selection boxes' column header
columnMenu / resizeColumn {name} column menu / Resize {name} A header's menu button and resize handle
sortAscending / sortDescending / clearSort Sort ascending / Sort descending / Clear sort The header menu's sort item
moveLeft / moveRight / hideColumn Move left / Move right / Hide column The header menu
resetColumns Reset columns, then (2 hidden) while columns are hidden The header menu's reset item; {n} the hidden columns
rowCount 1 row, 12 rows, then , 3 new when rows arrived What a screen reader hears once the count settles; {n} the rows, {m} the arrivals
newRows 3 new The button that takes a paused tape back to its end; {n} the arrivals
rejected Rejected A refused edit whose error is an Error with no message, a blank string, or anything else
notEditable This cell can't be edited now. An open editor's commit after its cell stopped taking edits
pending Pending A cell whose edit is sent and not yet back, said beside a rule's words
noRows No rows An empty view, unless emptyState says otherwise; the grid's description while it is empty

Watchlist, Blotter, Positions, RfqStack, and AuditTrailGrid pass labels through to the grid, and ParameterGrid's and QuotePanel's labels take these beside their own.

Exporting CSV

exportCsv(store, columns, ids) returns the rows ids names as CSV text, quoted as RFC 4180 has it, with a header row of each column's title and a column for each definition except one whose definition says hidden. Pass resolveColumns(columns, columnState) for the columns as the grid shows them, in its order and without the ones the column state hides. A cell is the column's format of its value, else the value as text, and nothing for null. A number's typographic minus goes out as the hyphen-minus where the cell then reads as one number, so a spreadsheet sums the negatives; one with a unit, as in −2.5mm, keeps its own. A cell a spreadsheet would run as a formula, one led by =, +, -, @, a tab, or a carriage return, goes out behind an apostrophe, unless a sign leads a cell that is wholly one plain number, digits with an optional point and exponent, which in a column whose value is a number may also group its thousands with commas.

What it does not do

The grid does not fetch data, group rows, render trees, paste multiple cells, support variable row heights, or persist state. Feed the store and save state through the callbacks; edits remain commands for your application to handle.

It lays out left to right only: frozen columns and the selection box stick to the left, and the keys scroll sideways as in a left-to-right document. Under dir="rtl" the frozen columns sit on the wrong side.

Built on shadcn's checkbox, context-menu, and dropdown-menu.

This page is docs/data-grid.md at v2.0.0.