A tiny, fast Svelte 5 data grid for fintech UIs — virtual scroll, canvas sparklines, batched realtime updates, sort/filter, selection, grouping, a server-side data source, export, and column pinning.
npm i bo-grid # peer: svelte@^5
npm i xlsx # optional, only for exportXLSX()
import { Grid, type ColumnDef, type GridRow } from 'bo-grid';
The grid component. Renders rows from an in-memory array or a RowSource. Not on Svelte? A framework-agnostic <bo-grid> custom element ships at bo-grid/element for React / Vue / Angular / vanilla — see React, Vue & more.
| Prop | Type | Description |
|---|---|---|
| rows* | GridRow[] | Row data (in-memory mode). Pass [] when using source. |
| columns* | ColumnDef[] | Column configuration. See Columns. |
| height* | number | string | A number is the scroll viewport's pixel height (element = that + chrome). A CSS string ('100%', '80vh') sizes the whole element and the viewport auto-fits the leftover space — give it a sized parent. |
| filter | string | Quick-filter; matches across column values. Default ''. |
| filterRow | boolean | Per-column filter input row under the header (AND across columns). In-memory mode only. |
| filterMenu | boolean | Header filter menu (funnel per column) with type-aware controls (text/number/date) + set filter (col.filter: 'set'). Lazy-loaded on first open. In-memory mode only. |
| quickFilter | boolean | Built-in search box above the grid; matches across all columns (ANDed with filter). With a source, its text reaches getRows as quickFilter. |
| fillHandle | boolean | Excel-style fill handle at the selection's corner; drag to copy the selected value(s) across (editable columns). In-memory mode only. |
| columnMenu | boolean | Per-column header menu (⋮ or Alt+↓): sort, pin, autosize, hide (all runtime). |
| columnsPanel | boolean | A "Columns" button opening a checklist panel to toggle column visibility (restore hidden columns). Lazy-loaded. |
| virtualizeColumns | boolean | Render only columns in the horizontal window (+ overscan) for 100+ column grids; forces fixed-width scroll, pinned columns always render. |
| onColumnVisibilityChange | (hidden: string[]) => void | Called with all hidden column keys when the runtime set changes (column-menu hide/show). |
| emptyMessage | string | Text shown when there are no rows. Defaults to labels.noRows. |
| loading | boolean | Show a loading overlay (consumer-driven async in in-memory mode). |
| rowMenu | (row) => { label, onSelect }[] | Right-click row menu items; each runs onSelect and closes. Keyboard: ContextMenu / Shift+F10 opens it at the focused cell. |
| detail | Snippet<[{ row }]> | Master-detail panel under each row; adds a leading expand toggle. In-memory mode. |
| detailHeight | number | Height (px) of the expanded detail panel. Default 160. |
| getChildren | (row) => GridRow[] | Tree data: return a row's children. rows become roots; renders an indented, expandable tree. |
| loadChildren | (row) => Promise<GridRow[]> | Async tree: load children on expand (server-backed); shows a loading row, then caches. Pair with hasChildren. |
| hasChildren | (row) => boolean | Cheap predicate for the expand chevron (no load). Required with loadChildren. |
| lazyGroups | { key; label?; count?; agg? }[] | Server-side grouping: group summaries; headers show the count + preformatted agg. Rows load via loadGroup. |
| loadGroup | (key) => Promise<GridRow[]> | Load a lazy group's rows on expand (loading row, then cached). Required with lazyGroups. |
| onRowReorder | (from, to) => void | Drag-to-reorder rows via a first-column handle; reorder your own data. Flat unsorted lists. |
| pageSize | number | Rows per page (> 0 shows a pager instead of one long scroll). In-memory mode. Default 0. |
| pageSizeOptions | number[] | Rows-per-page dropdown in the pager (e.g. [25, 50, 100]); picking one re-pages from 0. |
| onPageSizeChange | (size: number) => void | Called with the new page size when the pager's size dropdown changes. |
| page / onPageChange | number / (page) => void | Controlled current page (0-based) + change callback (URL-sync paging). |
| ariaLabel | string | Accessible name for the grid (aria-label on its role="grid" element, inside the .bo-grid box). |
| onColumnReorder | (keys: string[]) => void | New column-key order after a header drag-reorder. |
| onColumnResize | (key, width) => void | Column key + new width after a drag-resize (persist layout server-side). |
| groupBy | string[] | Column keys to group by (nested). In-memory mode only. Default []. |
| aggregations | AggKind[] | Stats shown in the selection footer. Default all five. |
| persistKey | string | Persist the user's column order and widths to localStorage under this key. |
| defaultColumn | Partial<ColumnDef> | Settings applied under every column; a column's own fields and its cellType win. |
| cellTypes | Record<string, CellTypeDef> | Reusable column types by name. A CellTypeDef is column defaults (format, compare, align, filter, width, component…) plus extends — the built-in type it builds on (default custom if it draws itself, else text). Columns opt in with cellType: 'name'. |
| filterTypes | Record<string, FilterTypeDef> | Custom filter kinds. Each has test(value, filter, row), an optional isActive(filter), an editor component (props { filter, onChange, column, values, labels }) and needsValues for distinct values. Columns opt in with filter: 'name'; an unregistered kind filters nothing. |
| labels | Partial<GridLabels> | Override any string the grid renders itself (menus, filter editor, pager, aria labels). Merged over English defaults; interpolated entries are functions, e.g. filterFor: (h) => `Lọc ${h}`. DEFAULT_LABELS lists every key. |
| locale | string | BCP 47 locale for built-in price/date/currency/relative formatting, the aggregation bar and the pager. A column's own locale wins. |
| onReady | (api: GridApi) => void | Called once on mount with the grid handle: scrollToRow(key, align?), focusCell(rowKey, colKey), getFocusedCell() (the focused cell's { row, column }, or null), getSelectedRows(), autosizeColumns(keys?), exportCSV(filename?), getState() / applyState(state) (layout snapshot reconciled against the current columns), patchRows(patches) — the realtime fast path: rows may be plain objects; ticks are written in place and only rendered rows repaint — refresh() (re-sort / re-filter the whole view against current values; see liveSort to keep it in order automatically), flashCells({ rows?, columns?, dir?, ms? }) — flash on-screen cells on demand (a fill, an alert) with the tick animation; returns how many rows flashed — pinRow(id, pinned?) / getPinnedRowIds() for runtime row pins, and openFind(query?) to open the find bar. |
| columnHover | boolean | Highlight the column under the pointer, body and header — handy on a wide board. One overlay for the whole column, not a class per cell; pinned columns included. Mouse and pen only. Colour: theme token colHover (default: the text colour at 6%). |
| pauseOnHover | boolean | While a mouse or pen is over the rows, liveSort waits, so a row does not move just as it is clicked; the rows re-sort once the pointer leaves, the window loses focus or the tab is hidden. Default true. |
| staleAfter | number | Dim a row (--bo-stale-opacity, 0.5) when nothing new has come for it for this many ms: a quote whose feed went quiet. News is a api.patchRows that changed the row, or a new object for it in rows (it arrived, or a copy replaced it); re-sending the same values is not. The row comes back the moment it ticks. Off by default. |
| flashNewRows | boolean | number | Flash a row once when it arrives in rows (a new order, a new fill): every cell, in the neutral flash colour, for this many ms (true: 600). A row arrives when its id is new: copies, the rows an empty grid gets and a wholesale replacement do not flash. Off by default. |
| liveSort | boolean | number | Keep the sort and filters current while api.patchRows changes values in place. When a patch changes a field the grid sorts or filters by, the rows it changed are merged back into place at most every liveSort ms (true: 500) instead of the whole view being re-sorted (~5 ms against ~21 ms on 100,000 rows with 500 changed). The selection stays on the same rows; nothing moves while a cell is being edited. Default off: rows hold their place until api.refresh(). |
| flashMotion | 'fade' | 'hold' | 'auto' | How a flash plays. 'fade' (default): the tint fades out. 'hold': the tint holds for the flash window (flashMs, 300 ms), then drops — no CSS animation, so a busy board restyles and repaints a flashing cell when it starts and when it ends instead of every frame (3–4× less main-thread work on the Price board). 'auto': each flash decides as it starts — it fades while few cells flash at once and holds once more than ~120 overlap. A flash that lights whole rows (flashNewRows, api.flashCells) counts each cell it lights. Legacy flash: true keeps its fade. |
| onViewportChange | (view: ViewportRange) => void | Called with the data rows on screen — { first, last, rows }, the rows in view plus the small rendered buffer, in view order — whenever that set changes (scroll, sort, filter, new data), never for a value-only tick. Pass it createViewportSubscriptions(...).update from bo-grid/realtime to stream only the symbols in view. |
| source | RowSource | Windowed/server data source. See Server-side. |
| resizable | boolean | Allow drag-to-resize column widths. A drag changes that column only; the others keep their widths and the ones after it move along. Double-click a grip to reset that column; Reset widths in the column menu resets them all. Default true; opt out per column with resizable: false. |
| rowNumbers | boolean | A leading row-number column, as in a spreadsheet: data rows numbered 1, 2, 3… in view order (group headers get none), sticky when the grid scrolls sideways. Its cells are row headers, so screen readers announce the number with each row; the header is the rowNumber label. Default false. |
| rowSelection | boolean | Show a leading checkbox column for whole-row selection (keyed by getRowId); Space toggles the focused row. Default false. |
| getRowId | (row) => string | number | Identity key for selection. Defaults to row.id; override for string/composite keys. |
| onRowSelectionChange | (ids: (string | number)[]) => void | Called with the selected row ids whenever the selection changes. |
| cellSelection | boolean | Highlight cells/ranges on click-drag (fill + focus ring). Set false for a read-only/display grid — clicks pass through to onRowClick/onCellClick. Default true. |
| selectedRowId | string | number | null | Controlled active-row highlight (keyed by getRowId) for list-detail. Independent of rowSelection and the range selection. |
| hiddenColumns | string[] | Column keys to hide (controlled). Drive your own column-picker UI. Default []. |
| rowClass | (row: GridRow) => string | Extra CSS class(es) per data row. Target with :global(.bo-grid .row.your-class). |
| onRowClick | (row, event) => void | Fired when a data row is activated by click or Enter. |
| hotkeys | Record<string, ({ row, column, event }) => void | boolean> | Keys for your app, run before the grid's own, with the focused row and column (null when none): { b: ({ row }) => buy(row), 'Shift+C': cancelAll }. A key after any of Shift+, Alt+, Ctrl+, Meta+, Mod+ (Ctrl or ⌘); case does not matter; a symbol by its character ('?'). Return false to leave the key to the grid. Not while editing, or in an input, button or menu; a key a binding took runs once when held. An unreadable or repeated binding throws. |
| onCellClick | ({ row, column, value }, event) => void | Fired when a cell is clicked (in addition to onRowClick). |
| sort | SortState[] | Controlled sort order (multi-key). Omit for uncontrolled sorting. |
| onSortChange | (sort: SortState[]) => void | Called with the new sort order on each header click. |
| columnFilters | Record<string, ColumnFilter> | Controlled column filters (keyed by column key). Omit for uncontrolled filtering. |
| onFilterChange | (filters) => void | Called with the full column-filter map whenever a header filter changes. |
| footer | boolean | Pinned totals row: each column with a groupAgg aggregates all rows. In-memory mode only. |
| pinnedRows | GridRow[] | Rows pinned to the top, always visible above the scroll (display-only). |
| fitColumns | boolean | Scale every column with the grid, in proportion to its width, instead of only flex columns taking up the change. A column never goes below its minWidth (without one: 64 px, or its own width if smaller) nor above its maxWidth; when the floors no longer fit, the grid scrolls sideways. It fits until the user sets a width (a drag, an autosize); Reset widths in the column menu brings the fit back. Default false. |
| findBar | boolean | Ctrl/⌘+F in the grid opens a find bar. It matches the cells' displayed text (case-insensitive), steps through matches with Enter / Shift+Enter, and focuses and scrolls to each; Escape closes it and returns focus to the grid. Off by default, as it takes Ctrl+F from the browser while the grid has focus. In-memory rows; up to 10,000 matches. |
| rowPinning | boolean | Users pin rows above the scroll from the row menu (right-click: "Pin to top" / "Unpin"). A pinned row stays in the body too and keeps updating (api.patchRows included); pins are remembered with persistKey and carried by getState(). Default false. |
| onRowPinChange | (ids: (string | number)[]) => void | Called with the pinned row ids, in pin order, whenever they change. |
| rowHeight | number | (row, i) => number | Uniform row height in px, or a function for variable heights (in-memory only). Default 36. |
| theme | 'dark' | 'light' | GridTheme | Built-in preset or a custom token map. Default 'dark'. |
| onCellEdit | (e: CellEditEvent) => void | Called when an editable cell commits (inline edit or paste). |
<Grid {rows} {columns} filter={q} groupBy={['sector']} height={640} />
The minimal contract every row must satisfy; add your own data fields alongside.
interface GridRow {
id: string | number; // stable identity (used as the render key)
flashSeq?: number; // legacy row-driven flash only (flash: true)
flashDir?: 'up' | 'down';
[field: string]: unknown;
}
$state so updates flash and re-render
only the changed cell — not the whole table. With flash: 'auto' the grid derives the
flash itself — write the new value and nothing else; flashSeq/flashDir
are needed only by the legacy flash: true mode.
ColumnDef is a discriminated union on type. All variants share these base options:
| Option | Type | Description |
|---|---|---|
| key* | string | Row field to read. |
| header* | string | Header label. |
| width | number | Fixed width (px). |
| minWidth / maxWidth | number | Clamp the column width while drag-resizing. |
| flex | number | flex-grow weight; column fills remaining space. |
| align | 'left' | 'right' | The side the header, cells and footer sit on. Defaults to right for number types and left for the rest. |
| cellClass | string | (value, row) => string | Extra class(es) on the column's cells (static or conditional). Target via :global. |
| headerClass | string | Extra class(es) on the column header. |
| flash | 'auto' | 'change' | boolean | Flash the cell on a value change. 'auto' (alias 'up-down'): the grid derives it per cell — green up, red down, nothing to maintain. 'change': neutral flash on any change. true: legacy row-driven mode via flashSeq/flashDir. |
| flashMs | number | Flash duration for the derived modes (default 300). |
| updateMs | number | Repaint this column at most every this many ms under api.patchRows. Each tick is still written into the row at once (sort, filters, totals and other columns see it); the cell shows the latest value on its next beat, and the first change after a quiet spell at once. A flash or showChange plays on the repaint. For values that change on every tick and are read at a glance (total volume). Applies to the field the column shows, not to value columns. |
| showChange | boolean | { format?, ms? } | Show the size of each change beside the value for a moment — ▲0.15 in the up colour, ▼ down — held, then faded (default 1000 ms). Written in the column's own format; format(delta, row) gets the signed change. In a narrow column the delta gives way first. Meant for one or two key columns: each change runs a short animation. |
| sortable | boolean | Set false to disable header-click sorting. |
| compare | (a, b) => number | Custom ascending comparator (enum priority, natural sort). In-memory mode. |
| group | string | Parent header label; consecutive columns sharing it render under one spanning header. |
| filter | 'text' | 'number' | 'date' | 'set' | string | false | Header filter-menu control for this column (with filterMenu): a built-in kind or the name of a registered filterTypes entry. Defaults to the column type; false disables it. |
| groupAgg | AggKind | Show this aggregate on group headers. |
| pinned | boolean | 'left' | 'right' | Pin to the left (true/'left') or right edge; stays visible during horizontal scroll. |
| resizable | boolean | Set false to disable drag-to-resize for this column. |
| autoWidth | boolean | Widen the column when a value on screen no longer fits (a price gaining a digit, a running total), checked at most every 500 ms by measuring the values with a canvas (no forced layout). It only grows; maxWidth caps it, and a width the user dragged or autosized takes over. |
| editable | boolean | Allow inline editing (double-click / Enter / F2, or type-to-edit). Numeric columns edit in a text input with a numeric keypad (a blank is refused), date columns in a date picker. |
| validate | (value, row) => boolean | Reject an edit (return false) to keep the old value. |
| options | string[] | Edit via a <select> of these choices (enum columns). |
| tooltip | boolean | (value, row) => string | Styled floating tooltip on each cell. true shows the formatted value; a function returns custom text (any type). Return '' to suppress. |
| headerTooltip | string | Styled tooltip on the column header (describe the metric/units). Hovering the header reveals it. |
| headerInfo | boolean | Show a small ⓘ cue in the header signalling a headerTooltip. |
| wrap | boolean | Let long content wrap to multiple lines instead of an ellipsis (pair with a taller rowHeight). Default false. |
| component | Component<CellTypeProps> | Svelte component that draws the cell; receives { value, row, column, text }. Display-only, like render. Updates in place: under api.patchRows, row is a fresh view of the patched row on each patch, so read it reactively rather than once at mount. |
| editor | Component<CellEditorProps> | Custom in-cell editor (with editable); receives { value, row, column, seed, commit, cancel }. Makes display widgets editable. Escape / focus-out cancel. |
| parse | (raw, row) => unknown | Turn typed or pasted text into the stored value (built-in editor, paste, fill). undefined or a non-finite number rejects. |
| cellType | string | Use a type registered through <Grid cellTypes> instead of type: its defaults apply under the column's own fields, and its extends built-in type drives sort/filter/export. |
| render | (ctx) => Node | string | JS cell renderer (framework-agnostic alt to the cell snippet). Return a DOM Node (safe) or HTML string (sanitize yourself). ctx = { value, row, column }; display-only. |
| format | (value, row?) => string | Custom display formatter (overrides the type formatter; applies to copy/export too). |
| spanRows | boolean | (a, b) => boolean | Merge the cell down over adjacent rows with equal (non-blank) values, or as a comparator decides. Hierarchical — a run breaks wherever a spanning column to its left breaks. Display only; in-memory. |
| colSpan | (row) => number | How many columns this cell covers for a row; covered cells are not drawn. Clamped at a pin boundary and the last column. Ignored under virtualizeColumns. |
| value | (row) => unknown | Computed column: derive the cell's value from the whole row (KPIs, ratios). Flows through sort/filter/aggregation/export. Not editable; in-memory. |
| dataBar | { min?; max?; color?; negative? } | Conditional formatting: in-cell bar behind the value, auto-ranged over the view (or explicit min/max); diverges around zero for signed columns. |
| icons | { at: number; icon: string; tone? }[] | Conditional formatting: icon beside the value, chosen by the highest threshold at ≤ the value; tone sets its colour. |
| colorScale | { min?; mid?; max?; colors? } | Conditional formatting: tint the cell background across the value range (auto-ranged over the view); mid gives a 3-stop diverging scale. |
| type | Extra options | Renders |
|---|---|---|
| 'text' | sub?: string | Bold value + optional dim sub-line (second field). |
| 'price' | — | 2-decimal number, monospace. |
| 'percent' | — | Signed %, green/red. |
| 'volume' | — | K/M/B/T suffix, negatives too. |
| 'number' | decimals?: number | Fixed-decimal number. |
| 'date' | dateStyle?: 'short'|'medium' | Formatted date (value is epoch ms). |
| 'currency' | currency?; locale?; decimals? | Localized currency via Intl.NumberFormat (default USD/en-US). |
| 'relative' | — | Epoch ms rendered as relative time (e.g. 3 hours ago). |
| 'heatmap' | min: number; max: number | Diverging red→green background ramp. |
| 'sparkline' | sparkKey: string | Canvas candlesticks from a Candle[] field. |
| 'progress' | min?: number; max?: number | In-cell progress bar (value mapped to min..max). |
| 'rating' | max?: number | Star rating (value of max, default 5). |
| 'tags' | value: string[] | Chips from an array (or comma-separated string). |
| 'badge' | tones?: Record<string, BadgeTone> | Status pill; colour per value via tones (up/down/amber/info/neutral). |
| 'boolean' | trueLabel?; falseLabel?: string | ✓ / ✕ with optional labels. |
| 'avatar' | sub?: string | Initials circle + name (optional secondary sub field). |
| 'link' | href?: (row) => string; newTab? | Anchor; href defaults to the value. URLs sanitized (javascript:/data: blocked). |
const columns: ColumnDef[] = [
{ type: 'text', key: 'symbol', sub: 'sector', header: 'Symbol', pinned: true },
{ type: 'price', key: 'price', header: 'Price', flash: 'auto', groupAgg: 'avg' },
{ type: 'percent', key: 'changePct', header: 'Chg %' },
{ type: 'heatmap', key: 'changePct', header: 'Heat', min: -5, max: 5 },
{ type: 'volume', key: 'volume', header: 'Volume', groupAgg: 'sum' },
{ type: 'sparkline', key: 'candles', sparkKey: 'candles', header: 'Trend', flex: 1 },
];
Click a header to sort (asc → desc → off); Shift+click more headers to sort by several columns at once. Drag a header to reorder, or its right-edge grip to resize (double-click the grip to reset).
Data bars, icon sets and colour scales paint analytics cues into numeric cells. Data bars auto-range over the current view (or set min/max; min: 0 for absolute bars) and diverge around a zero baseline for signed columns. Icon sets pick the rule whose at threshold is the greatest one ≤ the value. Colour scales tint the cell background across the range (a soft heat ramp; mid makes it diverging). All compose with flashing cells and add nothing to the core when unused.
const columns: ColumnDef[] = [
{ type: 'volume', key: 'marketValue', header: 'Mkt Value', dataBar: { min: 0 } },
{ type: 'number', key: 'pnl', header: 'P&L', decimals: 0,
dataBar: {}, // auto-ranged, diverges around 0
icons: [
{ at: -Infinity, icon: '▼', tone: 'down' },
{ at: 0, icon: '▲', tone: 'up' },
] },
{ type: 'number', key: 'pnlPct', header: 'P&L %', decimals: 1,
colorScale: { mid: 0 } }, // diverging tint around 0
];
Set value: (row) => … to derive a column from the whole row (KPIs, ratios, deltas). The derived value flows through display, sort, filter, group/footer aggregation, conditional formatting, export and copy — like a real column. key still names the column but need not be a real field; computed columns aren't editable. In-memory mode; keep value() cheap and pure (it runs during sort/filter).
{ type: 'price', key: 'total', header: 'Total',
value: (row) => row.qty * row.price, groupAgg: 'sum' }
For React and Vue, use the components: import { BoGrid } from 'bo-grid/react' (every <Grid> prop as a prop, the API on its ref) or 'bo-grid/vue' (props as attributes, callbacks as events, the API on the template ref's api); both load the element on the client, so server rendering is safe. Underneath them is a framework-agnostic, fully-typed custom element, for Angular and vanilla too. Import registers <bo-grid> and injects its styles; drive the whole <Grid> API through the config property. config is safe to set after the element attaches (the React ref + useEffect pattern won't crash). See React, Vue & more for per-framework recipes.
| Export | Type | Description |
|---|---|---|
| createBoGrid | (config?) => BoGridElement | Create a <bo-grid> with config preset, then attach it (safe create-before-attach). |
| BoGridConfig | type | The config object — every <Grid> prop (alias of GridProps). |
| BoGridElement | interface | HTMLElement with a typed config property. |
The main entry also exports a GridProps type (every <Grid> prop). For rich cells from plain JS, use a column's render(ctx) hook (see Columns) — the alternative to the Svelte cell snippet.
import { createBoGrid, type BoGridConfig } from 'bo-grid/element';
const config: BoGridConfig = { columns, rows, height: '100%' };
document.body.append(createBoGrid(config));
The pipeline between a market-data socket and the grid. A feed pushes far more messages than a screen can show; writing each one straight into reactive state means thousands of updates to paint 60 frames. This coalesces raw messages per key (cheap, no reactive writes), then drains a bounded slice once per animation frame. A separate import on its own size budget — it adds nothing to the grid core (~2 KB gzip), and it is framework-agnostic: the frame scheduler is injectable.
| Export | Signature | Purpose |
|---|---|---|
| serveFeed | ({ merge?, name?, onError?, scope? }?) => FeedSource | In a dedicated worker: the feed's end your socket pushes into. push(key, patch) coalesces per key until the page takes them; pending, connected. One per name per worker; it may start after an await. A patch that cannot be structured-cloned is dropped and reported. |
| connectFeed | (worker, { apply, cap?, name?, schedule?, onError? }) => WorkerFeed | On the page: asks the worker's serveFeed for one batch a frame (at most cap, 400) over a MessagePort of its own and hands it to apply (e.g. api.patchRows). start(), stop(), close(), connected, stalled, pending, applied; onError also hears of dropped patches, a missing serveFeed and a worker that stops answering. At 30,000 messages/s, 3–6% of the main thread against 14–25% reading the socket there (pnpm bench:feed). |
| createTickStream | (opts) => TickStream | Coalescing buffer + frame-budgeted drain. push(key, patch) to ingest, start()/stop() to run, flushNow() to drain everything, pending/applied for feed health. |
| TickBuffer | new TickBuffer(opts?) | The coalescing buffer on its own (push, drain(cap), size) when you own the scheduling. |
| createRowIndex | (rows, getRowId) => Map | Index rows by id so a patch finds its row in O(1). Rebuild when the row set changes, not per tick. |
| applyPatches | (index, batch) => number | Write a frame's patches onto their rows in place, skipping unchanged fields (no spurious re-render or flash). |
Options: apply (required — called once per frame with at most cap entries), cap (row writes per frame, default 400), merge (how two patches for one key combine; default field-wise for plain objects), schedule (frame scheduler, default requestAnimationFrame), onError (called if apply throws — that frame's batch is lost either way, since it's already off the buffer, but the loop keeps running; default re-throws asynchronously so the error isn't silently swallowed). A batch that throws is not counted in stream.applied.
import { createTickStream, createRowIndex, applyPatches } from 'bo-grid/realtime';
const index = createRowIndex(rows, (r) => r.symbol);
const stream = createTickStream({
apply: (batch) => applyPatches(index, batch), // once per frame, ≤ cap entries
cap: 400,
});
stream.start();
socket.onmessage = (e) => stream.push(JSON.parse(e.data).symbol, JSON.parse(e.data));
stream.pending rather than dropped frames. Pair it with flash: 'auto' columns and the
grid flashes each changed cell in the right direction on its own.
Two more pure pieces for the trading-desk surfaces — no component of their own, just the math/structure a ladder or a tape needs.
| Export | Signature | Purpose |
|---|---|---|
| centeredWindow | (totalCount, centerIndex, visibleCount) => { start, end } | The [start, end) slice of visibleCount contiguous indices centred on centerIndex, clamped to bounds. Feed levels.slice(start, end) to <Grid rows> instead of scrolling — centring is array slicing, not scroll control. Even visibleCount puts the extra row on the lower-index side. |
| createLinger | ({ ms, key, onExpire? }) => Linger | Rows that leave a live list stay a moment: update(rows) returns the rows plus those that left within ms, each right after the row it followed; leaving(row) says which to style; onExpire asks for another update when one is due to go; size; stop(). For a filled or cancelled order seen going instead of vanishing. |
| createOrderBook | ({ maxLevels? }?) => OrderBook | A book kept from a depth feed: snapshot({ bids, asks, seq? }), then update({ bids?, asks?, from?, seq? }) with the changed [price, size] levels, numbers or numeric strings (size 0 removes). It returns 'applied', 'stale' (a seq not newer than the last) or 'gap' (from past the next number: take a new snapshot). Read back with top(n, { group? }) (best first), rows(n, { group? }) (stacked: asks above bids, with total depth and share; a level keeps its row id, so flash: 'auto' flashes real changes) and pairs(n, { group? }) (the nth bid beside the nth ask). group merges levels into buckets, bids down and asks up. Also best(), crossed, levels, version (bumps on every change), seq, clear(). |
| createAlerts | ({ key, rules, fireOnFirst?, onChange? }) => Alerts | Alerts that stay lit until acknowledged. Each named rule on a row fires when it turns true and stays lit, even if the price comes back; check(rows, { complete? }) (all rows or the changed ones; complete forgets keys not given) returns the alerts that just fired. onChange runs when the lit alerts change, to restyle. lit(row) / isLit(row, rule?) for a rowClass; ack(key, rule?) darkens (a rule still holding waits until it stops before it can fire again), ackAll(); active() newest first for a panel; size, version (bumps on fire and acknowledge), forget(key), clear(). |
| TradeTape | new TradeTape(capacity?) | Capped ring buffer for a trade log — push(trade) O(1) regardless of session length, toArray() a fresh snapshot newest-first (call once per render), size/capacity, clear(). Default capacity 500. Unlike a price update, a trade is never coalesced — every one shows. |
import { centeredWindow, TradeTape } from 'bo-grid/realtime';
// Ladder: 120 levels, 16 visible, centred on the spread (index 60)
const { start, end } = centeredWindow(120, 60, 16);
const visible = levels.slice(start, end);
// Tape: capped, newest trade first
const tape = new TradeTape(500);
socket.onmessage = (e) => tape.push(JSON.parse(e.data));
rows = tape.toArray();
lockedCenter: number | null works well: null follows the live centre, a
number freezes the window there until the viewer re-locks. See the Price ladder and
Time & sales demos.
Market-convention helpers for APAC trading screens. Pure functions only — no ColumnDef changes, no Grid/Cell wiring. Wire tone/formatting into a column via the cell snippet or a JS render(ctx) hook you already have. A separate import on its own budget (~3 KB gzip) — adds nothing to the grid core.
The APAC convention: colour by position relative to the daily ceiling/floor/reference, not just up/down — a price pinned at the limit is a different signal from an ordinary move.
| Export | Signature | Purpose |
|---|---|---|
| resolveTone | (value, bands, opts?) => Tone | Classify a value against { ref, ceiling?, floor? } → 'ceiling' | 'floor' | 'ref' | 'up' | 'down'. ceiling/floor are optional — omit for plain up/down/ref. opts.epsilon absorbs float noise at a boundary (default 1e-6). |
| toneColor | (tone, colors?) => string | Tone → CSS colour: purple ceiling, cyan floor, yellow ref, green up, red down. Uses darkToneColors by default; pass lightToneColors on a light theme, or a partial object for your own tones. Both palettes keep every tone at WCAG AA (4.5:1) on the grid's presets for their side. |
Standard non-special-session rules only — no IPO first-day band, no ex-dividend reference adjustment. A broker with exact business rules should compute bands server-side and hand { ref, ceiling, floor } straight to resolveTone.
| Export | Signature | Purpose |
|---|---|---|
| vnTickSize | (price, exchange?) => number | Minimum price increment at this price. HOSE steps 10 → 50 → 100 VND by price band; HNX/UPCOM flat 100 VND. Default HOSE. |
| vnBandPercent | (exchange) => number | Daily band width: HOSE 7%, HNX 10%, UPCOM 15%. |
| vnBands | (ref, exchange?) => ToneBands | Ceiling/floor from a reference price — ceiling rounded down to a tick, floor rounded up, so neither exceeds the true regulatory limit. |
| vnFuturesBands | (ref) => ToneBands | VN30 index futures: ±7% of the reference (VN_FUTURES_BAND) on a 0.1-point step (VN_FUTURES_TICK), ceiling rounded down and floor up. |
| roundToTick | (price, tick, mode?) => number | 'down' | 'up' | 'nearest' (default). Works in tick units to avoid float drift on a fractional tick (an FX pip). |
| Export | Signature | Purpose |
|---|---|---|
| fmtTradingPrice | (value, tick, locale?) => string | Formats at the precision the tick implies — 0 decimals for a whole-VND tick, 4 for an FX pip — instead of the built-in price type's fixed 2. |
| decimalsForTick | (tick) => number | The decimal count a tick size implies, on its own. |
| fmtBps | (fraction, { decimals?, sign?, unit?, locale? }?) => string | A fraction in basis points: 0.00125 → '12.5 bp'. sign: true puts + on a rise; unit: '' leaves the number bare. |
| fmtFraction | (price, { denominator?, sub?, style?, separator? }?) => string | A whole number and parts, as Treasuries are quoted: 99-16+ is 99 and 16½ 32nds. sub 2, 4 or 8 splits each part, shown as one last character: eighths for style: 'cash' (+ a half; 2, 6 the quarters), the first decimal digit for 'futures' ("99'165"; quarters 2, 5, 7). denominator: 8 and separator: "'" for grains. |
| parseFraction | (text, { denominator?, style? }?) => number | Reads what fmtFraction writes, or a plain decimal, for a column's parse; pass the options the column formats with. NaN (a refused edit) for anything else. |
| Export | Signature | Purpose |
|---|---|---|
| sessionStateAt | (date, schedule, opts?) => SessionState | 'pre-open' | 'ato' | 'continuous' | 'break' | 'atc' | 'closed'. Evaluated via Intl in opts.timeZone (default Asia/Ho_Chi_Minh) — correct for a viewer in any time zone. opts.skipWeekends (default true) reports 'closed' on Sat/Sun. |
| VN_HOSE_SCHEDULE | SessionWindow[] | HOSE's standard weekday schedule: ATO 09:00–09:15, continuous 09:15–11:30, break 11:30–13:00, continuous 13:00–14:30, ATC 14:30–14:45. Doesn't account for holidays or special sessions. |
| sessionLabel | (state) => string | A short display label for a session state. |
import { vnBands, resolveTone, toneColor, fmtTradingPrice, vnTickSize } from 'bo-grid/trading';
const bands = vnBands(row.ref, 'HOSE'); // { ref, ceiling, floor } — tick-rounded
const tone = resolveTone(row.price, bands); // 'ceiling' | 'floor' | 'ref' | 'up' | 'down'
const color = toneColor(tone); // CSS colour for that tone
const text = fmtTradingPrice(row.price, vnTickSize(row.price, 'HOSE')); // "68,500"
type: 'custom' column's cell snippet (or a JS render(ctx) hook) exactly like any other custom cell. See the VN board demo, which composes it with bo-grid/realtime.
| Export | Signature | Purpose |
|---|---|---|
| fxPipSize | (pair) => number | 0.01 when the quote currency is the yen, 0.0001 otherwise; any spelling ('USD/JPY', 'usdjpy', a broker's 'USDJPY.m'). Elsewhere a pip size (below 1) can stand in for a pair: metals, or currencies with another pip such as HUF and KRW. |
| fxQuoteParts | (price, pairOrPip, { pipette? }?) => { big, pips, pipette } | The quote cut as a dealing screen shows it: 1.08452 → '1.08', '45', '2'. pipette: false stops at the pip. |
| fxQuoteHtml | (price, pairOrPip, { pipette? }?) => string | The parts as HTML for a column's render: the big figure dimmed, the pips large, the pipette raised, in classes bo-fx-big, bo-fx-pips, bo-fx-pipette. |
| pipsBetween | (a, b, pairOrPip) => number | a - b in pips, to a tenth: a spread or a move. |
| fxMarketOpen | (date) => boolean | The FX week: Sunday 17:00 to Friday 17:00, New York time. |
| fxCentresOpen | (date) => FxCentre[] | Which of Sydney, Tokyo, London and New York are in business hours (their own time zones, summer time included) while the week is open. Two at once is an overlap. |
| CRYPTO_SCHEDULE | SessionWindow[] | Continuous round the clock: with { timeZone: 'UTC', skipWeekends: false }, sessionStateAt reads 'continuous' always. |
| fmtSigPrice | (value, digits = 5, locale?) => string | A price to significant digits, for a watchlist that runs from 63,250 to 0.0000012; every digit of a large price's integer part is kept. |
Positions from fills, on average cost: what a blotter's positions view shows.
| Export | Signature | Purpose |
|---|---|---|
| createPositions | () => PositionBook | fill({ symbol, side: 'buy' | 'sell', qty, price, fee? }) moves the symbol's position and returns it: { symbol, qty, avgPrice, realized, bought, sold } (qty above 0 long, below 0 short). A fill against the position realizes P&L on the closed part at the average price; one through flat opens the rest at the fill price; fees come off realized (a negative fee, a rebate, adds); fractional sizes that add up to flat are flat. get(symbol), all(), clear(). An empty symbol, an unknown side, a quantity not above 0, or a price or fee that is not a number throws. |
| unrealizedPnl | (position, price) => number | The open position marked at price. Keep the last price as a field of each position row and compute this in a value column: a groupAgg: 'sum' footer then follows every tick. |
A price ladder (depth of market) built on the grid: one row per price on the tick grid, highest first, with the bid and ask sizes, their depth from the best price outward as bars, and your own working orders beside each side. It follows the market without moving under the pointer, and reports what the user asked for: placing and cancelling stay the app's. A separate import (~4 KB gzip on top of the grid).
| Prop | Type | Description |
|---|---|---|
| bids / asks | readonly [price, size][] | The resting book, in any order. A repeated price is summed; float noise in a feed still meets its level. |
| tick | number | (price) => number | The price step, or a function of the price for a tick table ((p) => vnTickSize(p, 'HOSE')). The step down from a band's first price uses the band below. A positive number, at least 1e-8; prices at or below zero are not shown. |
| last | number | The last trade's price: its row is tinted. |
| orders | LadderOrder[] | Your working orders, { id, side: 'buy' | 'sell', price, qty }, summed per price beside their side. |
| rows / rowHeight | number | Prices on screen (default 21) and row height (default 24). |
| center | 'mid' | 'last' | What it follows: the middle of the spread (default) or the last trade. It recentres only once the market leaves the middle half of the window, and never while the pointer is over it or a cell has focus. |
| following | boolean (bindable) | Whether it follows the market. The wheel pages through prices and turns it off (bind it to see that); Space, setting it back to true, or recenter() centres on the market again. It never moves while the pointer is over it or the keyboard works in it. |
| onAction | (action: LadderAction, event) => void | A click on the bid side asks to 'buy' at that row's price, on the ask side to 'sell', on your orders to 'cancel' them; B, S and Delete do the same on the focused row (a held key acts once). { kind: 'buy' | 'sell', price, via } or { kind: 'cancel', price, side?, orders, via }, via being 'click' | 'key', with the price as the row showed it. |
| format / locale | (price) => string / string | The price text. Default: the tick's decimals in locale ('en-US'). |
| headers / theme / ariaLabel | LadderHeaders / GridTheme / string | Column headers (myBid, bid, price, ask, myAsk), the grid theme, the accessible name. |
import { DepthLadder } from 'bo-grid/ladder';
<DepthLadder {bids} {asks} {last} {orders} tick={0.05} bind:following
onAction={(a) => a.kind === 'cancel' ? venue.cancel(a.orders) : venue.place(a.kind, a.price)} />
ladderPrices(center, count, tick), ladderRows(prices, { bids, asks, orders?, last?, format? }), midPrice, bestPrices, stepPrice, snapToTick, ordersAt.
Click a cell, then drag or Shift+click to extend a rectangular selection. When more than one cell is selected a footer shows live Sum / Avg / Count / Min / Max over the numeric cells in the range.
| Keys | Action |
|---|---|
| ↑ ↓ ← → | Move the focus cell. |
| Shift + arrows | Extend the selection. |
| Home / End | First / last column in the row (add Ctrl/⌘ for the first / last cell). |
| PageUp / PageDown | Move up / down by one viewport page. |
| Ctrl/⌘ + A | Select all. |
| Ctrl/⌘ + C | Copy selection as TSV (Excel-pasteable). |
| Ctrl/⌘ + V | Paste TSV into editable cells from the top-left of the selection. |
| Ctrl/⌘ + Z / Y | Undo / redo edits, paste and fill (a paste or fill is one step). |
| Alt + ↓ | Open the column menu at the focused column (with columnMenu). |
| Enter / type a character | Edit the focused cell (type-to-edit seeds the editor with the key), or activate onRowClick. |
| Space | Toggle row selection for the focused row (with rowSelection). |
| ContextMenu / Shift+F10 | Open the row context menu (with rowMenu) at the focused cell. |
| Esc | Clear the selection. |
Your hotkeys | Run before all of the above, on the focused row. The focus, the selection and an open editor stay on their rows as rows are added, removed or reordered. |
Pass groupBy (column keys) for single or nested grouping. Groups are collapsible, headers stick to the top while scrolling, and any column with a groupAgg shows a live subtotal. Group headers are the same height as rows, so virtual scrolling stays smooth.
<Grid {rows} {columns} groupBy={['sector', 'exchange']} height={640} />
Back the grid with a RowSource to load only the visible window — the dataset can be far larger than memory. Sort and filter are delegated to the source; unloaded rows render as skeletons. (Grouping is client-only.)
interface RowSource {
getRows(p: {
range: { start: number; end: number }; // half-open window
sort: SortState | null; // primary key (= sorts[0])
sorts?: SortState[]; // full multi-column order
filter: string; // the filter prop's text
quickFilter?: string; // the built-in search box's text; match both
columnFilters?: Record<string, AnyFilter>; // filter menu, by column key
}): RowSourceResult | Promise<RowSourceResult>;
}
interface RowSourceResult { rows: GridRow[]; total: number; }
const source: RowSource = {
async getRows({ range, sort, filter, quickFilter }) {
const r = await fetch(`/api/rows?offset=${range.start}&limit=${range.end - range.start}`);
return r.json(); // { rows, total }
},
};
<Grid {columns} {source} height={640} />
Adapts an in-memory array to RowSource — handy for client-side data or testing the path.
createArraySource(rows, { latency?: number, filterKeys?: string[] }): RowSource
| Function | Signature |
|---|---|
| toCSV | (rows, columns, opts?) => string |
| exportCSV | (filename, rows, columns, opts?) => void |
| exportXLSX | (filename, rows, columns, opts?) => Promise<void> |
| parseCSV | (text, columns?) => GridRow[] |
| parseTSV | (text, columns?) => GridRow[] |
| parseJSON | (text) => GridRow[] |
| rowsFromObjects | (objects) => GridRow[] |
| parseRows | (text, columns?) => GridRow[] |
| parseCSVMatrix | (text) => string[][] |
| toHTMLTable | (rows, columns) => string |
| printTable | (rows, columns, { title? }) => void |
toHTMLTable/printTable render all rows (the live grid virtualizes) to a clean, escaped table — for printing or PDF capture.
parseCSV/parseTSV are the inverse of toCSV (RFC4180-aware): map headers to columns, coerce numeric/date columns, stamp id + flash fields. rowsFromObjects adapts a JSON/API array (own id or index + flash fields). parseRows auto-detects JSON/TSV/CSV — handy for a paste handler.
ExportOptions: { formatted?: boolean; header?: boolean }. Numeric columns export as raw numbers unless formatted is set; sparkline columns are skipped. exportXLSX dynamic-imports the optional xlsx peer (separate lazy chunk).
Dark-first and self-contained — no CSS import required. Override any token by setting the matching --bo-grid-* custom property on an ancestor, or pass the theme prop a custom token map.
Eight built-in presets are exported: darkTheme, lightTheme, highContrastDark, highContrastLight, midnightTheme, terminalTheme, and tradecanvasTheme / tradecanvasLightTheme (the palette TradeCanvas charts and TradingDek share) — plus a themePresets name→preset map and a ThemePreset type for a theme picker. Pass any to theme.
| Token | Default | Token | Default |
|---|---|---|---|
| --bo-grid-bg | #1a1a1a | --bo-grid-up | #34d399 |
| --bo-grid-header-bg | #0f0f0f | --bo-grid-down | #f87171 |
| --bo-grid-row-a | #131313 | --bo-grid-amber | #f59e0b |
| --bo-grid-row-b | #0f0f0f | --bo-grid-sel-fill | indigo 16% |
| --bo-grid-row-hover | #1f1f24 | --bo-grid-sel-border | #6366f1 |
| --bo-grid-text | #e5e5e5 | --bo-grid-header-h | 28px |
| --bo-grid-text-dim | #8a8a8a | --bo-grid-mono | SF Mono… |
| --bo-grid-border | white 6% | --bo-grid-sans | Inter… |
| --bo-grid-scheme | dark | native controls (checkbox / date / spinners / scrollbars) — light or dark | |
| --bo-grid-radius | 8px | --bo-grid-font-size | 13px |
| --bo-grid-cell-pad | 8px | horizontal cell/header padding (density lever; pair with rowHeight) | |
.light-grid {
--bo-grid-bg: #fff;
--bo-grid-text: #1a1a1a;
--bo-grid-up: #16a34a;
--bo-grid-down: #dc2626;
}