Blotter

An order blotter backed by a row store, with server-provided status and actions on selected or focused orders.

import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { Blotter, BlotterGrid, type BlotterRow } from "@/components/ui/blotter"

const TIME = Date.UTC(2026, 8, 23, 14, 30)

export default function BlotterDemo() {
  const [store] = useState(() => {
    const rows = createRowStore<BlotterRow>({ getRowId: (row) => row.id, lane: "ordered" })
    rows.applyDeltas({ upsert: [
      { id: "O-1", time: TIME, symbol: "ES", side: "buy", quantity: 10, filled: 0, price: 5012.25, status: "Working", account: "A-1" },
      { id: "O-2", time: TIME + 1000, symbol: "CL", side: "sell", quantity: 5, filled: 5, price: 78.1, status: "Filled", account: "A-2" },
    ] })
    return rows
  })

  return (
    <div className="h-40 w-fit max-w-full">
      <Blotter store={store}>
        <BlotterGrid sort={{ key: "time", dir: "desc" }} />
      </Blotter>
    </div>
  )
}

Installation

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

Usage

import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { Blotter, BlotterGrid, type BlotterRow } from "@/components/ui/blotter"

const TIME = Date.UTC(2026, 8, 23, 14, 30)

export default function BlotterDemo() {
  const [store] = useState(() => {
    const rows = createRowStore<BlotterRow>({ getRowId: (row) => row.id, lane: "ordered" })
    rows.applyDeltas({ upsert: [
      { id: "O-1", time: TIME, symbol: "ES", side: "buy", quantity: 10, filled: 0, price: 5012.25, status: "Working", account: "A-1" },
      { id: "O-2", time: TIME + 1000, symbol: "CL", side: "sell", quantity: 5, filled: 5, price: 78.1, status: "Filled", account: "A-2" },
    ] })
    return rows
  })

  return (
    <div className="h-40 w-fit max-w-full">
      <Blotter store={store}>
        <BlotterGrid sort={{ key: "time", dir: "desc" }} />
      </Blotter>
    </div>
  )
}

Give the grid a stable row store and a container with a height. The timestamps are fixed and display in your local timezone. Your feed owns subsequent updates. Use the shared preview alignment control to keep the left edge fixed while resizing columns.

Composition

Compose the grid and order controls beneath Blotter:

Blotter
├── BlotterNewButton
├── BlotterSelection
├── BlotterActionScope
│   └── BlotterActionButton
└── BlotterGrid
    └── renderContextMenu
        └── BlotterActionScope with explicit ids
            └── BlotterActionMenuItem

The root coordinates selection and commands. Add, move or omit controls in your own markup. Each action scope shares permission readings across its children without adding an element.

Permission-filtered actions

Use BlotterActionButton and BlotterActionMenuItem to show permitted counts and recheck permissions when invoked. Cancel simulates an immediate server response. Amend and New order record requests. Finish first order revokes its permissions; Restore orders resets the data.

Save this complete example as blotter-actions.tsx beside consumers that import its OrderToolbar and OrderMenu recipes, outside components/ui. The recipes preserve custom menu content. Unavailable toolbar buttons return focus to the toolbar; a revoked menu item keeps focus and becomes inert.

import { useLayoutEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"
import { createRowStore } from "@/lib/row-store"
import { Blotter, BlotterActionButton, BlotterActionMenuItem, BlotterActionScope, BlotterGrid, BlotterNewButton, BlotterSelection, blotterColumns, useBlotter, useBlotterActions, type BlotterRow } from "@/components/ui/blotter"

const unavailable = (node: HTMLElement) => !node.isConnected || node.matches(':disabled, [aria-disabled="true"], [data-disabled]')

// Recover only while an action owns focus; an ordinary blur releases that ownership.
export function useOrderActionFocus<T extends HTMLElement = HTMLDivElement>(focusFallback?: (container: T, action: HTMLElement) => void) {
  const ref = useRef<T>(null)
  const focused = useRef<HTMLElement | null>(null)
  useLayoutEffect(() => {
    const node = focused.current
    if (!node || !unavailable(node)) return
    if (document.activeElement === node || document.activeElement === document.body) {
      const target = ref.current
      if (target) {
        if (focusFallback) focusFallback(target, node)
        else target.focus()
      }
    }
    focused.current = document.activeElement === node ? node : null
  })
  return {
    ref,
    onFocusCapture(event: FocusEvent<T>) { focused.current = (event.target as HTMLElement).closest<HTMLElement>("[data-action]") },
    onBlurCapture(event: FocusEvent<T>) {
      const node = focused.current
      if (!node || !unavailable(node) || (event.relatedTarget && event.relatedTarget !== document.body)) focused.current = null
    },
  }
}

export function OrderToolbar({ newLabel = "New order" }: { newLabel?: ReactNode }) {
  return <BlotterActionScope><OrderControls newLabel={newLabel} /></BlotterActionScope>
}

function OrderControls({ newLabel }: { newLabel: ReactNode }) {
  const { canNew } = useBlotter()
  const { actions } = useBlotterActions()
  const focus = useOrderActionFocus()
  return <div {...focus} tabIndex={-1} role="toolbar" aria-label="Orders" className="flex shrink-0 flex-wrap items-center gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-ring/40">
    {canNew && <BlotterNewButton>{newLabel}</BlotterNewButton>}
    <BlotterSelection />
    {actions.map(action => <BlotterActionButton key={action.id} action={action.id} />)}
  </div>
}

/** Custom content precedes the permitted actions. Pass hasCustom when a renderer can return undefined. */
export function OrderMenu({ children, hasCustom = children !== undefined }: { children?: ReactNode; hasCustom?: boolean }) {
  const { ids, actions } = useBlotterActions()
  const key = JSON.stringify(ids)
  const offered = () => actions.filter(action => action.allowedIds.length > 0).map(({ id, label }) => ({ id, label }))
  const [menu, setMenu] = useState(() => ({ key, items: offered() }))
  // Keep open-menu positions stable under the pointer; live parts disable revoked commands.
  if (menu.key !== key) setMenu({ key, items: offered() })
  const focus = useOrderActionFocus((container, action) => {
    // Keep the revoked command inert under the keyboard, just as it stays under the pointer.
    if (action.isConnected) action.focus()
    else container.closest<HTMLElement>('[role="menu"]')?.focus()
  })
  return <div {...focus} role="group" aria-label="Order menu" className="outline-none">
    {children}
    {hasCustom && menu.items.length > 0 && <ContextMenuSeparator />}
    {menu.items.map(item => <BlotterActionMenuItem key={item.id} action={item.id} className="focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">{actions.some(action => action.id === item.id) ? undefined : item.label}</BlotterActionMenuItem>)}
    {!hasCustom && menu.items.length === 0 && <ContextMenuItem disabled>Nothing to do here</ContextMenuItem>}
  </div>
}

const orders: BlotterRow[] = [
  { id: "O-1", time: 1, symbol: "ES", side: "buy", quantity: 10, filled: 0, status: "Working", allowedActions: ["cancel", "amend"] },
  { id: "O-2", time: 2, symbol: "CL", side: "sell", quantity: 5, filled: 2, status: "PartiallyFilled", allowedActions: ["cancel"] },
  { id: "O-3", time: 3, symbol: "GC", side: "buy", quantity: 2, filled: 2, status: "Filled", allowedActions: [] },
]
const columns = blotterColumns().filter((column) => ["symbol", "quantity", "status"].includes(column.key)).map(column => column.key === "symbol" ? { ...column, width: 100 } : column)

export default function BlotterActionsDemo() {
  const [store] = useState(() => {
    const rows = createRowStore<BlotterRow>({ getRowId: (row) => row.id, lane: "ordered" })
    rows.applyDeltas({ upsert: orders })
    return rows
  })
  const [event, setEvent] = useState("No request yet")

  return (
    <>
      <div data-demo-controls className="flex flex-wrap gap-2 text-xs">
        <button type="button" className="rounded border border-border px-2 py-1" onClick={() => store.applyDeltas({ upsert: orders })}>Restore orders</button>
        <button type="button" className="rounded border border-border px-2 py-1" onClick={() => store.applyDeltas({ patch: [{ id: "O-1", fields: { filled: 10, status: "Filled", allowedActions: [] } }] })}>Finish first order</button>
      </div>
      <div className="w-96 max-w-full space-y-3 text-xs lining-nums tabular-nums">
        <div className="h-52 min-w-0">
          <Blotter store={store} onNew={() => setEvent("New order requested")} actions={[
            { id: "cancel", label: "Cancel", destructive: true, run: (_rows, ids) => {
              store.applyDeltas({ patch: ids.map((id) => ({ id, fields: { status: "Cancelled", allowedActions: [] } })) })
              setEvent(`Cancelled: ${ids.join(", ")}`)
            } },
            { id: "amend", label: "Amend", run: (_rows, ids) => setEvent(`Amend requested: ${ids.join(", ")}`) },
          ]}>
            <OrderToolbar />
            <BlotterGrid columns={columns} deleteAction="cancel" renderContextMenu={(_, ids) => <BlotterActionScope ids={ids}><OrderMenu /></BlotterActionScope>} />
          </Blotter>
        </div>
        <p role="status">{event}</p>
      </div>
    </>
  )
}

Custom layout

Use useBlotterActions for a native action picker beside the grid, with New above it and the target count below. The picker focuses its select before dispatch. When the chosen definition disappears, it clears the choice and disables submission until you select again.

Save the preceding actions example as blotter-actions.tsx beside this file. Its useOrderActionFocus helper returns focus to the select if an external update disables the active submit button.

import { useRef, useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { Blotter, BlotterActionScope, BlotterGrid, BlotterNewButton, BlotterSelection, blotterColumns, useBlotterActions, type BlotterRow } from "@/components/ui/blotter"
import { useOrderActionFocus } from "./blotter-actions"

const orders: BlotterRow[] = [
  { id: "O-1", time: 1, symbol: "ES", side: "buy", quantity: 10, status: "Working", allowedActions: ["cancel", "amend"] },
  { id: "O-2", time: 2, symbol: "CL", side: "sell", quantity: 5, status: "Filled", allowedActions: [] },
]
const columns = blotterColumns().filter(column => ["symbol", "status"].includes(column.key)).map(column => column.key === "symbol" ? { ...column, width: 100 } : column)

export function OrderActionPicker() {
  const select = useRef<HTMLSelectElement>(null)
  const focus = useOrderActionFocus<HTMLFormElement>(() => select.current?.focus())
  const { ids, actions, run } = useBlotterActions()
  const [actionId, setActionId] = useState("cancel")
  const action = actions.find(action => action.id === actionId)
  // A disappearing choice must not silently select or later restore another command.
  if (actionId && !action) setActionId("")
  return <form {...focus} className="grid content-start gap-2 text-xs lining-nums tabular-nums" onSubmit={event => {
    event.preventDefault()
    select.current?.focus()
    if (action) run(action.id)
  }}>
    <label className="grid gap-1">Order action
      <select ref={select} value={action?.id ?? ""} onChange={event => setActionId(event.target.value)} className="min-w-0 rounded border border-border bg-background px-2 py-1">
        <option value="">{actions.length ? "Choose an action" : "No actions available"}</option>
        {actions.map(action => <option key={action.id} value={action.id}>{action.label}</option>)}
      </select>
    </label>
    <p aria-live="polite" aria-atomic="true">{action ? `${action.allowedIds.length} of ${ids.length} orders permit ${action.label}.` : actions.length ? "Choose an action." : "No actions available."}</p>
    <button type="submit" data-action={action?.id ?? ""} disabled={!action?.allowedIds.length} className="rounded border border-border px-2 py-1 disabled:opacity-50">{action ? `Apply ${action.label}` : "Apply action"}</button>
  </form>
}

export default function BlotterLayoutDemo() {
  const [store] = useState(() => {
    const rows = createRowStore<BlotterRow>({ getRowId: row => row.id, lane: "ordered" })
    rows.applyDeltas({ upsert: orders })
    return rows
  })
  const [event, setEvent] = useState("Select an order")
  const [amend, setAmend] = useState(true)
  const [selection, setSelection] = useState<ReadonlySet<string>>(() => new Set())
  const [focusedRowId, setFocusedRowId] = useState<string | null>(null)
  return <>
    <div data-demo-controls className="flex flex-wrap gap-2 text-xs">
      <button type="button" className="rounded border border-border px-2 py-1" onClick={() => store.applyDeltas({ upsert: orders })}>Restore orders</button>
      <button type="button" className="rounded border border-border px-2 py-1" onClick={() => { store.clear(); setSelection(new Set()); setFocusedRowId(null) }}>Clear orders</button>
      <button type="button" className="rounded border border-border px-2 py-1" onClick={() => setAmend(value => !value)}>{amend ? "Remove" : "Restore"} Amend</button>
    </div>
    <Blotter store={store} selection={selection} onSelectionChange={setSelection} focusedRowId={focusedRowId} onFocusedRowChange={setFocusedRowId} onNew={() => setEvent("Ticket requested")} className="w-[36rem] max-w-full gap-3" actions={[
      { id: "cancel", label: "Cancel", run: (_, ids) => {
        store.applyDeltas({ patch: ids.map(id => ({ id, fields: { status: "Cancelled", allowedActions: [] } })) })
        setEvent(`Cancelled: ${ids.join(", ")}`)
      } },
      ...(amend ? [{ id: "amend", label: "Amend", run: () => setEvent("Amend requested") }] : []),
    ]}>
      <div className="flex items-center justify-between gap-2"><h2 className="text-sm font-medium">Order review</h2><BlotterNewButton>Open ticket</BlotterNewButton></div>
      <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]">
        <div className="h-40 min-w-0"><BlotterGrid columns={columns} selectionColumn={false} /></div>
        <BlotterActionScope><OrderActionPicker /></BlotterActionScope>
      </div>
      <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><BlotterSelection className="ml-0" /><p role="status">{event}</p></div>
    </Blotter>
  </>
}

Server reports and status

Receive report applies the next server report. The second fills the order while its status remains PartiallyFilled; the third reports Filled. Reset reports starts the sequence again.

import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { Blotter, BlotterGrid, blotterColumns, type BlotterRow } from "@/components/ui/blotter"

const order: BlotterRow = { id: "O-1", time: 1, symbol: "ES", side: "buy", quantity: 4, filled: 0, status: "Working" }
const reports = [
  { filled: 1, status: "PartiallyFilled" },
  { filled: 4, status: "PartiallyFilled" },
  { filled: 4, status: "Filled" },
]
const columns = blotterColumns().filter((column) => ["symbol", "quantity", "filled", "status"].includes(column.key))

export default function BlotterReportsDemo() {
  const [store] = useState(() => {
    const rows = createRowStore<BlotterRow>({ getRowId: (row) => row.id, lane: "ordered" })
    rows.applyDeltas({ upsert: [order] })
    return rows
  })
  const [step, setStep] = useState(0)

  function receive() {
    const report = reports[step]
    if (!report) return
    store.applyDeltas({ patch: [{ id: order.id, fields: report }] })
    setStep(step + 1)
  }

  return (
    <>
      <div data-demo-controls className="flex flex-wrap items-center gap-2 text-xs lining-nums tabular-nums">
        <button type="button" className="rounded border border-border px-2 py-1 disabled:opacity-50" disabled={step === reports.length} onClick={receive}>Receive report</button>
        <button type="button" className="rounded border border-border px-2 py-1" onClick={() => { store.applyDeltas({ upsert: [order] }); setStep(0) }}>Reset reports</button>
        <span role="status">Reports received: {step}</span>
      </div>
      <div className="h-40 w-fit max-w-full">
        <Blotter store={store}>
          <BlotterGrid columns={columns} selectionColumn={false} label="Order reports" />
        </Blotter>
      </div>
    </>
  )
}

API Reference

Blotter

Blotter<T> is a native div with required children. T extends BlotterRow and defaults to it. RowId is a string.

The root forwards native props, refs and events and renders only the children you supply.

Prop Type Default Purpose
store RowStore<T> Required Orders, keyed by a stable row id.
children ReactNode Required Your grid, controls and surrounding content.
onNew () => void None Receive new-order requests.
actions readonly BlotterAction<T>[] Empty list Available command definitions without generated controls.
selection ReadonlySet<RowId> Internally owned empty set Control selected rows.
onSelectionChange (selection: ReadonlySet<RowId>) => void None Receive selection changes.
focusedRowId RowId | null Internally owned null Control row focus.
onFocusedRowChange (id: RowId | null) => void None Receive focus changes.
className string None Classes on the outer tradecn-blotter container.

Selection and focus callbacks work in either mode. When controlled, apply the corresponding callback's value.

The root does not subscribe to row updates.

BlotterGrid

BlotterGrid<T> uses the blotter preset: 24 px rows, multiple selection, fill flashes, a 750 ms reorder hold, arrival highlights, viewport pinning and debounced row-count announcements. The checkbox column is enabled by default. Individual grid options can override preset defaults.

Use the same row type for Blotter<T> and BlotterGrid<T> when supplying custom columns. Context cannot infer a generic row type from the parent JSX.

Prop Type Default Purpose
columns ColumnDef<T>[] Built-in columns Replace the built-in columns.
price (value: number, row: T) => string Two decimals Format built-in price cells, unused with custom columns.
time (ms: number) => string Local HH:MM:SS Format built-in timestamps, unused with custom columns. A time a Date can't hold, such as NaN, prints – instead of calling it. The default reads the runtime's time zone, so server-rendered times use the server's.
label string "Blotter" Accessible grid name.
selectionColumn boolean true Show checkboxes in multiple-selection mode.
deleteAction string None Action id for Delete and Backspace on the focused grid.
renderContextMenu (rows, ids, target) => ReactNode None Complete caller-owned menu content; target is the row the menu opened on.
className string None Classes on the tradecn-blotter-grid sizing wrapper.
ref Ref<HTMLDivElement> None Ref to the sizing wrapper.
onKeyDown (event) => void None Receives bubbling keys before the optional action. Prevent default to cancel it.

Omitting columns builds blotterColumns({ price, time }). The menu renderer receives rows: T[], ids: RowId[], and target?: RowId | null, the row the menu opened on. onKeyDown receives a React KeyboardEvent<HTMLDivElement> from the sizing wrapper.

Other DataGrid inputs pass through, including sorting, column state, filtering, editing, footer totals and activation. Store, preset, selection and focus come from the root.

A row is named by its cells, so a screen reader can read the focused row again when a fill or a status changes it, which on a blotter is news. Pass getRowLabel for a name that holds, and the grid reads the row's cells once when focus rests on it instead; a fill or a status change on the focused row then goes unsaid until focus moves.

The wrapper fills its parent's height and can flex within the root. Give a separate block wrapper a height when moving the grid into another layout.

Blotter installs the same shared files as DataGrid and Watchlist.

Action scope and controls

BlotterActionScope requires children and accepts optional ids: readonly RowId[]. Omit ids to use root targets: the selection, or the focused row when nothing is selected. Pass explicit ids for a context menu or an individual order.

One scope subscribes once per distinct target id, including missing rows that may return. Multiple controls share those subscriptions.

Explicit ids preserve input order and duplicates in counts and dispatched rows. Only listeners are deduplicated. Pass unique ids when each order should appear once.

Part Own props Default content Behavior
BlotterNewButton Installed Button props New order Calls onNew, disabled when absent.
BlotterSelection Native span props {n} selected, empty at zero Polite live reading of root targets, even inside an explicit scope.
BlotterActionButton Required action: string, installed Button props Label and permitted count Requires a scope, disabled for unknown or unavailable actions.
BlotterActionMenuItem Required action: string, installed ContextMenuItem props Label, omitting the count for one target Requires a scope, disabled for unknown or unavailable actions.

All controls forward refs, classes, events and children.

Button parts default to compact styling and type="button". Explicit sizes pass through to the installed Button; size={null} suppresses its size-variant classes. BlotterActionButton draws an action its definition marks destructive as an outline button with destructive text, which holds 4.5 to 1 at rest and under the pointer in every style, where the destructive variant puts that text on its own tint; pass a variant to choose your own.

The menu item keeps the installed default variant.

Prevent default in onClick to cancel dispatch. Custom children replace generated labels, including explicit null.

Use the shared recipes above for ordinary controls and menus.

Your layout owns grouping, accessible names for custom controls, and a useful focus destination when live permissions remove or disable the active control.

Available buttons retain focus after activation. Unavailable toolbar buttons return focus to the toolbar. A revoked menu item keeps focus with a visible ring, and Enter does nothing until that same action is restored or you navigate to another item. The custom picker keeps its select available.

A labelled toolbar groups the ordinary buttons, which remain reachable with Tab.

Hooks

useBlotter() returns selection and commands without subscribing to rows. It requires Blotter.

Member Type Meaning
selection ReadonlySet<RowId> Current selection.
focusedRowId RowId | null Current focused row.
targets readonly RowId[] Selection, or the focused row when selection is empty.
select (selection: ReadonlySet<RowId>) => void Request a selection change.
focus (id: RowId | null) => void Request a focus change.
canNew boolean Whether a new-order callback exists.
newOrder () => void Invoke the current new-order callback.
run (action: string, ids: readonly RowId[]) => void Dispatch a defined action after checking current store permissions.

useBlotterActions() requires BlotterActionScope and returns { ids, actions, run }. ids is the scope's ordered target list.

Each BlotterActionState contains id, label, optional destructive, and allowedIds: readonly RowId[]. It exposes no raw handler.

Its run(action: string) uses the scope's current ids and the same checked root command. Permission readings are display state, never authorization for later dispatch.

BlotterRow

BlotterRow field Type Required Meaning
id string Yes Order id, used as the store key in the example.
time number Yes Timestamp in milliseconds since the Unix epoch.
symbol string Yes Instrument symbol.
side BlotterSide ("buy" | "sell") Yes Order side.
quantity number Yes Order quantity.
filled number | null No Filled quantity.
price number | null No Order price.
status string Yes Server-provided status, displayed unchanged.
account string No Account text.
allowedActions readonly string[] No Action ids currently allowed for this order.

The status is the server's

Blotter never derives status from quantities. An order filled 5,000 of 5,000 still says PartiallyFilled until the server changes it. The server may know about a bust, correction, or fill still in flight.

A status change flashes flat because it has no numeric direction.

Actions are the server's too

Each order's allowedActions lists the actions the server permits now. An absent or empty list permits none. Define available commands with BlotterAction<T>:

Field Type Required Purpose
id string Yes Match against each order's allowedActions.
label string Yes Action verb, such as "Cancel"; controls add the count.
run (rows: T[], ids: RowId[]) => void Yes Receive only currently permitted rows and their store ids.
destructive boolean No Use the destructive toolbar-button style without changing the menu item.

The ordinary button shows Cancel 3 when all three targets allow it, Cancel 2 of 3 when one does not, and a disabled Cancel when none do. BlotterSelection reports the number of root targets as "selected", including a focused-row fallback. Targets a filter has hidden stay in the count and in the action — working orders off screen are still cancelled — while removed orders leave the action but stay counted until your application prunes the selection.

Permissions are checked when controls render and again against the store when invoked. Only the second check's permitted rows reach run. If none remain, it is not called. allowedActions is the server's last report, not a guarantee.

Handle confirmation, submission and rejection in run. Blotter does not await a returned promise, show pending state or handle errors.

An action scope listens to its target rows. A row update changes permission readings and the affected grid row without a grid-wide render. Unrelated updates do not wake the scope.

Pass renderContextMenu to enable a menu. The shared OrderMenu recipe captures actions permitted for at least one target when opened. It shows a disabled "Nothing to do here" item if none are offered.

An open menu keeps its action positions stable. Permission loss disables an item, and restoration enables it again. Newly offered actions appear when the menu reopens or its targets change.

A single target shows Cancel. Multiple or mixed targets get counts.

Place custom content inside OrderMenu to keep it before a conditional separator and the permitted actions.

Set hasCustom when a supplied renderer can return undefined. Explicit null counts as custom content by default. Pass hasCustom={false} to request the fallback for empty output.

The renderer receives the grid's target rows and ids without action-permission filtering.

The menu acts on the selection, or with nothing selected on the row it opened on, which it keeps while it stays open, even after that row leaves the view. Right-clicking plain row content requests focus for that row and, when selection is enabled, replaces the selection if that row was not already selected.

Apply these requests when controlling selection or focus. Until then, a selection that doesn't hold the row stays what the menu acts on.

Action invocation rechecks the store even if the displayed count has become stale.

allowedRows<T>(store: RowStore<T>, ids: readonly RowId[], action: string): { rows: T[]; ids: RowId[] } exposes the same filter for your own hotkeys or menus. It reads the store immediately, skips missing or disallowed rows, and preserves input order and duplicates. It does not run an action or change the store.

Delete cancels nothing by default

deleteAction="cancel" runs the matching action when Delete or Backspace is pressed on the focused grid. It uses the selection, or the focused row when the selection is empty, rechecks permissions before dispatch, and runs once per press: a held key's repeats run nothing more. When the focused order leaves the view, as a fill can take it, focus goes to the order now at its place, so with nothing selected a Delete pressed after that acts on that order; a selection, which the grid never prunes, stays what Delete acts on, the departed order included.

Deletion keys are off by default. Nothing runs when the event was prevented, no rows are in hand, no matching action exists or no target still allows it.

Cell editors, custom controls, header controls, selection checkboxes, nested grids and portaled content keep their own key behavior. onKeyDown still receives bubbling events before this check.

After using a control, press Shift+Tab until the grid has focus to preserve selection. Clicking a cell without a control also focuses the grid and selects that row.

Use action buttons, menu items or useBlotterActions().run for commands elsewhere in your layout.

Columns

blotterColumns<T>(options?: BlotterColumnOptions<T>): ColumnDef<T>[] accepts optional price and time callbacks with the signatures above. It returns time, symbol, side, quantity, filled, price, status, and account. Spread the list into your own to add a column, drop one, or reorder.

price gets the order because instruments print differently. Quantity and filled use formatQuantity. Missing price, filled, or account values display NULL_TOKEN (–).

Side displays as BUY or SELL, with the up and down colors respectively, for a side of buy or sell in any case; any other side the server sends displays as sent, in the text color. The words identify the side without relying on color.

Filled and price flash on change. Quantity and time do not.

The default view uses store order. Pass sort={{ key: "time", dir: "desc" }} for newest first.

Keep columns, price, and time stable with a module constant, useMemo, or useCallback. New columns can re-render rows. When using built-in columns, changing price or time rebuilds the list.

actions, renderContextMenu, onSelectionChange and onFocusedRowChange can be inline without repainting grid rows. Commands use the latest committed definitions, and the grid receives the current menu renderer directly.

Adding

onNew reports a new-order request. Open your ticket there. The order appears when your feed upserts it.

Blotter does not await a promise returned by onNew or handle its errors.

What it does not do

Build or send an order, confirm a cancel, group by parent order, or calculate order-specific totals. Use the grid's footer for totals you define. It has no notion of fills as rows of their own. Use a second blotter over a second store.

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

This page is docs/blotter.md at v2.0.0.