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 docs/frameworks.md.
| Prop | Type | Description |
|---|---|---|
| rows* | GridRow[] | Row data (in-memory mode). Pass [] when using source. |
| columns* | ColumnDef[] | Column configuration. See Columns. |
| height* | number | Viewport height in px. |
| 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). In-memory mode only. |
| 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. Default 'No matching rows'. |
| 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. |
| 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 the role="grid" root). |
| 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. |
| source | RowSource | Windowed/server data source. See Server-side. |
| resizable | boolean | Allow drag-to-resize column widths. Default true; opt out per column with resizable: 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: number[]) => void | Called with the selected row ids whenever the selection changes. |
| 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. |
| 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). |
| 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: number; // stable identity (used as the render key)
flashSeq: number; // increment to trigger a cell flash
flashDir: 'up' | 'down';
[field: string]: unknown;
}
$state so updates flash and re-render
only the changed cell — not the whole table. Bump flashSeq on each tick to flash a
flash: true column.
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' | Header/text alignment. |
| 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 | boolean | Amber flash on value change (drives off flashSeq). |
| 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' | false | Header filter-menu control for this column (with filterMenu). 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. |
| editable | boolean | Allow inline editing (double-click / Enter, or type-to-edit). Numeric columns use a number input, date columns 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 | Set a native title tooltip (full value) on each cell. |
| format | (value, row?) => string | Custom display formatter (overrides the type formatter; applies to copy/export too). |
| 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 suffix. |
| '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: true, 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 dashboards, bo-grid/charts ships tiny, dependency-free SVG charts. It is a separate import, so it adds nothing to the grid core (~2 KB gzip on its own). Use them standalone or inside a grid cell via a custom column. Theme with color/colors props or --boc-color / --boc-1…6 CSS vars.
| Component | Key props | Renders |
|---|---|---|
| LineChart | data: number[]; area?; color? | Line (optional filled area). |
| BarChart | data: number[]; gap?; color? | Bars from a zero axis (signed-aware). |
| DonutChart | data: number[] | {value,color?}[]; thickness? | Donut, or pie when thickness ≥ size/2. |
| StackedBarChart | data: number[][]; grouped?; seriesLabels? | Multi-series bars (data[series][category]) — stacked, or grouped side-by-side. |
| Legend | items: {label, color?}[] | Swatch + label list, palette-aware. |
Bar / stacked / donut elements carry an SVG <title> for native hover tooltips (the value, plus a label when set).
import { LineChart, BarChart, DonutChart } from 'bo-grid/charts';
<LineChart data={[3, 5, 4, 8, 6, 9]} area />
<DonutChart data={[{ value: 5 }, { value: 3 }]} />
The SVG geometry helpers (linePoints, barRects, donutArcs, extent, …) are exported too, for building your own charts.
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. |
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;
}): RowSourceResult | Promise<RowSourceResult>;
}
interface RowSourceResult { rows: GridRow[]; total: number; }
const source: RowSource = {
async getRows({ range, sort, filter }) {
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.
Six built-in presets are exported: darkTheme, lightTheme, highContrastDark, highContrastLight, midnightTheme, terminalTheme — 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;
}