bo-grid
v0.25.0
Published
Tiny, fast Svelte 5 data grid: canvas sparklines, batched realtime cell updates, and virtual scrolling. A free, fintech-focused alternative to heavyweight grids.
Maintainers
Readme
bo-grid
Tiny, fast Svelte 5 data grid for fintech UIs — canvas sparklines, batched realtime cell updates, and virtual scrolling, with a core that gzips to ~31 KB (Svelte external; unused exports tree-shake). A free alternative to the heavyweight grids that paywall these features.
Live demo · API reference · Benchmarks · Roadmap
The demo is a gallery of grid types — a realtime Trading desk, a grouped Portfolio with subtotals and pivot, an editable Spreadsheet, a live Order book, a Correlation heatmap, a Dashboard with in-cell charts, a Wide 60-column grid, a server-backed Lazy tree, and more — switch between them with the tabs.
Status: actively developed. Working: config-driven columns, virtual scroll, sort (single / multi / controlled), filtering (global, per-column row, header filter menus with set / number / date filters, quick search, controlled + server-side), multi-cell selection + live aggregation, grouping (nested, sticky, subtotals), pivot, tree data, master-detail, a server-side
RowSourcefor huge datasets, CSV/Excel export, column management (reorder, resize, pin L/R, hide, autosize, tool panel, column menu), spreadsheet editing (inline + typed editors, validation, copy/paste, fill handle, undo/redo), row selection, pagination, sparklines, realtime flash, heatmaps, theming, and full keyboard a11y. SSR/SvelteKit-safe. Unit tests (Vitest), type-check, a headless mount smoke-test, an SSR render check, and library + demo bundle-size budgets all run in CI. A formal WCAG audit is the main thing left — see the roadmap.
Why
| | Heavy enterprise grids | bo-grid | | --- | --- | --- | | Price | $$$ / dev / year | Free (MIT) | | Sparklines | paid tier | built in | | Realtime cell updates | DIY / complex | built-in primitive | | Bundle | hundreds of KB | ~31 KB gzip core (benchmarks) | | Svelte | wrapper | native Svelte 5 |
bo-grid ships most of the features other grids put behind a paid (Enterprise) tier — grouping, pivot, tree data, master-detail, range selection, Excel export, sparklines — for free, and runs in any framework via a custom element.
Install
npm i bo-grid
# peer dependency: svelte@^5Works with SvelteKit / SSR out of the box — <Grid> server-renders to HTML
without touching window/document/localStorage (a CI gate, pnpm ssr,
proves it). The package is sideEffects: false, so unused exports tree-shake
away. See the SvelteKit guide for load-function data,
server-side / lazy loading, realtime feeds, import helpers, charts, printing, and
layout persistence.
Usage
<script lang="ts">
import { Grid, type ColumnDef, type GridRow } from 'bo-grid';
const columns: ColumnDef[] = [
{ type: 'text', key: 'symbol', sub: 'sector', header: 'Symbol', width: 132 },
{ type: 'price', key: 'price', header: 'Price', width: 88, flash: true },
{ type: 'percent', key: 'changePct', header: 'Chg %', width: 84 },
{ type: 'heatmap', key: 'changePct', header: 'Heat', min: -5, max: 5, width: 76 },
{ type: 'volume', key: 'volume', header: 'Volume', width: 90 },
{ type: 'date', key: 'listedAt', header: 'Listed', dateStyle: 'short', width: 92 },
{ type: 'sparkline', key: 'candles', sparkKey: 'candles', header: 'Trend', flex: 1 },
];
// Rows must expose `id`, `flashSeq`, `flashDir` plus your data fields.
// Make the hot fields `$state` so updates flash without re-rendering the table.
let rows: GridRow[] = $state(/* ... */);
let filter = $state(''); // bind to your own search input
</script>
<Grid {rows} {columns} {filter} height={640} />Realtime updates
A cell with flash: true plays a brief amber flash whenever the row's
flashSeq increments. Drive it from your data source (e.g. a WebSocket):
row.flashDir = next >= row.price ? 'up' : 'down';
row.price = next;
row.flashSeq++; // triggers the flash on the price cellOnly on-screen rows render DOM, so off-screen updates cost nothing until they
scroll into view. Batch bursty feeds into a requestAnimationFrame flush to
keep frames smooth.
React, Vue, Angular & vanilla
bo-grid also ships a framework-agnostic custom element. Import it and drive
the whole API through a config property:
import 'bo-grid/element'; // registers <bo-grid>, injects styles
const el = document.querySelector('bo-grid');
el.config = { columns, rows, theme: 'dark', height: 520 };It works in React, Vue, Angular and plain HTML — see
docs/frameworks.md for per-framework recipes and
examples/ for runnable, build-free starters. (Custom
cell/detail snippets are Svelte-only; use built-in types, format, or computed
value from other frameworks. Native Svelte users should import Grid directly —
smaller, and snippets work.)
Column types
Data: text · price · percent · volume · number · date · currency ·
relative · heatmap · sparkline
Rich: progress · rating · tags · badge · boolean · avatar · link
Escape hatch: custom
Rich types render value as a widget — handy well beyond fintech (CRM, projects, admin, content). All are themed from the design tokens:
const columns: ColumnDef[] = [
{ type: 'avatar', key: 'name', header: 'Member', sub: 'role' },
{ type: 'badge', key: 'status', header: 'Status',
tones: { Active: 'up', Away: 'amber', Offline: 'neutral' } },
{ type: 'progress', key: 'done', header: 'Progress', min: 0, max: 100 },
{ type: 'rating', key: 'score', header: 'Rating', max: 5 },
{ type: 'tags', key: 'skills', header: 'Skills' }, // value: string[]
{ type: 'boolean', key: 'remote', header: 'Remote', trueLabel: 'Remote', falseLabel: 'Office' },
{ type: 'link', key: 'email', header: 'Email', href: (r) => `mailto:${r.email}` },
{ type: 'relative', key: 'seen', header: 'Last seen' }, // value: epoch ms → "3h ago"
{ type: 'currency', key: 'rate', header: 'Rate', currency: 'USD' },
];link sanitizes its href (javascript:/data: are blocked); relative formats
an epoch-ms value as relative time; currency localizes via Intl.NumberFormat.
Sizing: width (px) or flex (grow weight). See ColumnDef for per-type options.
Custom cells
Use type: 'custom' and pass a cell snippet to render anything — badges,
buttons, links. The snippet receives { row, column, value }:
{#snippet cell({ row })}
<span class:up={row.changePct > 0}>{row.changePct > 0 ? '▲' : '▼'}</span>
{/snippet}
<Grid {rows} {columns} {cell} height={640} />Conditional formatting
Paint analytics cues straight into numeric cells — no custom snippet needed.
Data bars (dataBar): an in-cell bar behind the value, scaled across the
column's range. The range auto-computes over the current view, or set min/max
(min: 0 gives absolute proportional bars). When the range spans negatives, bars
diverge left/right around a zero baseline. color/negative override the default
up/down theme colours.
Icon sets (icons): an icon beside the value, chosen by the highest threshold
at that is ≤ the value. Each rule carries a semantic tone (up · down ·
amber · info · neutral) for its colour.
Colour scales (colorScale): tint the cell background across the value range —
a soft, themed heat ramp. Auto-ranges over the view (or set min/max); pass
mid for a 3-stop diverging scale; colors overrides the stops. Works on any
numeric column (the fixed heatmap type still exists for an absolute ramp).
const columns: ColumnDef[] = [
// Proportional bar from zero:
{ type: 'volume', key: 'marketValue', header: 'Mkt Value', dataBar: { min: 0 } },
// Diverging bar (auto-ranged) + an icon keyed by sign:
{ type: 'number', key: 'pnl', header: 'P&L', decimals: 0,
dataBar: {},
icons: [
{ at: -Infinity, icon: '▼', tone: 'down' },
{ at: 0, icon: '▲', tone: 'up' },
] },
// Diverging colour scale around zero (auto-ranged):
{ type: 'number', key: 'pnlPct', header: 'P&L %', decimals: 1, colorScale: { mid: 0 } },
];All three compose with flashing/live cells and add nothing to the core for grids
that don't use them. (In-memory auto-ranging; pass explicit min/max in source
mode.)
Computed columns
Derive a column's value from the whole row with value: (row) => … — KPIs,
ratios, deltas. The derived value flows through display, sort, filter, group/footer
aggregation, conditional formatting, export and copy, just like a real column.
key still names the column (and is the sort/filter key) but need not be a real
field. Computed columns aren't editable (there's no field to write back).
const columns: ColumnDef[] = [
{ type: 'number', key: 'qty', header: 'Qty' },
{ type: 'price', key: 'price', header: 'Price' },
// No `total` field on the row — derived, and still sortable/filterable/exportable:
{ type: 'price', key: 'total', header: 'Total', value: (row) => row.qty * row.price,
groupAgg: 'sum' },
];In-memory mode (a server source owns its own derivations). Keep value() cheap
and pure — it's called during sort and filter.
Charts (companion)
For dashboards, bo-grid/charts ships tiny, dependency-free SVG charts —
LineChart, BarChart, DonutChart, StackedBarChart (stacked or grouped
multi-series), and a Legend. They're a separate import, so they add nothing
to the grid core (~3 KB gzip on their own). Use them standalone, or inside a grid
cell via a custom column. Bar/stacked/donut elements carry an SVG <title>, so
hovering shows the value (accessible, zero-JS).
<script>
import { LineChart, BarChart, DonutChart, StackedBarChart, Legend } from 'bo-grid/charts';
</script>
<LineChart data={[3, 5, 4, 8, 6, 9]} width={160} height={40} area />
<BarChart data={[4, 8, 6, 9, 7]} color="var(--up)" />
<DonutChart data={[{ value: 5, label: 'A' }, { value: 3, label: 'B' }]} />
<!-- data[series][category]; stacked by default, `grouped` for side-by-side -->
<StackedBarChart data={[[3, 5, 2], [4, 1, 6]]} seriesLabels={['Q1', 'Q2']} />
<Legend items={[{ label: 'Q1' }, { label: 'Q2' }]} />Theme them with color / colors props, or by setting --boc-color and
--boc-1…--boc-6 CSS vars on any ancestor. The geometry helpers (linePoints,
barRects, donutArcs, …) are exported too, for rolling your own SVG charts. See
the Dashboard example for charts inside grid cells.
Row height
Uniform 36px by default. Pass rowHeight as a number for a different density, or
a function for variable per-row heights (in-memory mode):
<Grid {rows} {columns} rowHeight={48} height={640} />
<Grid {rows} {columns} rowHeight={(row, i) => (row.expanded ? 96 : 36)} height={640} />Variable heights use a prefix-sum + binary-search virtualizer, so scrolling stays O(log n). Source mode is uniform-only (unloaded row heights aren't known).
Pagination
Prefer pages over one long scroll? Set pageSize (> 0) for a paged view with a
first/prev/next/last pager; rows still virtualize within each page. In-memory mode.
<Grid {rows} {columns} height={640} pageSize={25} />Sort & filter
Click a column header to sort (asc → desc → off). Shift-click additional
headers to sort by multiple columns — each sorted header shows its position in
the order. Sparkline columns aren't sortable; set sortable: false on any column
to opt out. Sorting is a snapshot — rows hold position while their values update
in place (trading-grid behaviour), so a realtime feed never reshuffles the view.
Pass a filter string to quick-filter rows (matches across column values). Drive
it from your own search input — or set quickFilter for a built-in search box
above the grid. Set filterRow to add a row of per-column filter inputs under
the header (rows must match every non-empty column filter; in-memory mode).
For richer filtering, set filterMenu to add a funnel to each column header.
Clicking it opens a menu whose control matches the column type — text
(contains / equals / starts / ends), number (=, ≠, <, ≤, >, ≥, between), or date
(before / after / on / between). Set col.filter: 'set' for a set filter — a
searchable checkbox list of the column's distinct values (All / None). The menu
is lazy-loaded on first open, so it costs nothing until used; disable it per
column with col.filter: false:
<Grid {rows} {columns} height={640} filterMenu />Filtering is uncontrolled by default. To own the filter state (persist it, set
initial filters, sync to the URL), pass a controlled columnFilters map and
handle onFilterChange — mirrors controlled sort. In server (source) mode
the filter menu still works: text/number/date filters are delegated to your
RowSource via params.columnFilters (set filters need in-memory data).
Sorting is uncontrolled by default. To own it (persist it, set an initial sort,
or sync to the URL), pass a controlled sort array and handle onSortChange:
<Grid {rows} {columns} height={640} {sort} onSortChange={(s) => (sort = s)} />Selection & aggregation
Click a cell, then drag or Shift-click to extend a rectangular selection. Keyboard: arrows move, Shift+arrows extend, Home/End jump to the first/last column (+Ctrl/⌘ for the first/last cell), PageUp/PageDown move by a page, Ctrl/⌘+A select all, Ctrl/⌘+C copy the selection as TSV (Excel-pasteable), Ctrl/⌘+V paste, Esc clear.
Paste writes a TSV block (from a spreadsheet or the grid's own copy) into
editable cells starting at the selection's top-left. A single copied value fills
the whole selection (Excel behaviour); a block clamps to the grid edges. Pasted
values flow through the same validation as inline editing — non-editable columns
and invalid numbers are skipped — and each accepted cell emits onCellEdit, so
paste only does anything when you've wired that callback.
Set fillHandle for an Excel-style fill handle: the selection grows a small
square at its bottom-right corner; drag it down or right to copy the selected
value(s) across the extended range (editable columns; multi-cell selections tile).
Edits, paste and fill are undoable with Ctrl/⌘+Z (redo with Ctrl/⌘+Y). A paste or fill undoes as a single step.
When more than one cell is selected, a footer bar shows live Sum / Avg / Count / Min / Max over the numeric cells in the range — and it keeps updating as a realtime feed ticks. Choose which stats to show:
<Grid {rows} {columns} aggregations={['sum', 'avg', 'count']} height={640} />Set footer for a pinned totals row: every column with a groupAgg shows
that aggregate over all (filtered) rows, sticky to the bottom as you scroll
(in-memory mode).
<Grid {rows} {columns} height={640} footer />Pass pinnedRows to keep rows stuck to the top, always visible above the
scroll — a benchmark, a summary, or "your position". They render with the normal
columns (and rowClass) but are display-only:
<Grid {rows} {columns} height={640} pinnedRows={[benchmark]} />Row selection
Set rowSelection for a leading checkbox column — whole-row selection keyed by
row.id, so it survives sorting and filtering (unlike the positional cell
selection above). The header checkbox selects/clears all matching rows, and
Space toggles the focused row from the keyboard.
onRowSelectionChange reports the selected ids:
<Grid
{rows}
{columns}
height={640}
rowSelection
onRowSelectionChange={(ids) => (selected = ids)}
/>In server (source) mode the per-row checkboxes work on loaded rows; the
select-all header checkbox is disabled (unloaded ids can't be enumerated).
Selection keys off row.id by default; pass getRowId for string/UUID/composite
keys (getRowId={(r) => r.uuid}).
Grouping
Pass groupBy (column keys) to group rows — single or nested. Groups are
collapsible (click the header) and show live subtotals under any column with
a groupAgg set:
<script>
const columns = [
{ type: 'price', key: 'price', header: 'Price', groupAgg: 'avg' },
{ type: 'volume', key: 'volume', header: 'Volume', groupAgg: 'sum' },
// …
];
</script>
<Grid {rows} {columns} groupBy={['sector', 'exchange']} height={640} />Group headers are the same height as data rows, so virtual scrolling stays smooth over very large grouped sets. Subtotals recompute live as the feed ticks, and the current group's header stays pinned to the top as you scroll within it.
Server-side grouping
When the data is grouped on the server (or too large to fetch upfront), pass
lazyGroups (the group summaries) and loadGroup (load a group's rows on
expand). Headers show the server-provided count and preformatted aggregates; rows
load lazily with a loading row, then cache. See the Server groups example.
<Grid
rows={[]}
{columns}
lazyGroups={[
{ key: 'North America', count: 142, agg: { amount: '$2.4M' } },
{ key: 'Europe', count: 98, agg: { amount: '$1.8M' } },
]}
loadGroup={(key) => fetch(`/api/orders?group=${key}`).then((r) => r.json())}
height={520}
/>Theming
Dark-first and self-contained — no CSS import required. Use the theme prop with
a built-in preset or a custom token map:
<Grid {rows} {columns} theme="light" height={640} />
<Grid {rows} {columns} theme={{ bg: '#0b1020', up: '#22d3ee' }} height={640} />Six built-in presets are exported (GridTheme): darkTheme, lightTheme,
highContrastDark, highContrastLight, midnightTheme, terminalTheme — plus a
themePresets name→preset map (and a ThemePreset type) for a theme picker:
<script>
import { Grid, themePresets, type ThemePreset } from 'bo-grid';
let preset: ThemePreset = 'midnight';
</script>
<Grid {rows} {columns} theme={themePresets[preset]} height={640} />Or set any --bo-grid-* custom property on an ancestor — the prop is just a
convenience over these:
.my-app {
--bo-grid-bg: #fff;
--bo-grid-text: #1a1a1a;
--bo-grid-up: #16a34a;
--bo-grid-down: #dc2626;
}Native form controls (checkboxes, date pickers, number spinners, search inputs,
scrollbars) follow the theme automatically via color-scheme + accent-color.
A custom theme defaults to dark; set scheme: 'light' (or --bo-grid-scheme:
light) for a light one.
Tokens cover colour, typography (mono/sans/fontSize), shape (radius),
and density (cellPad, plus the rowHeight prop) — so the whole look is yours.
Numeric columns use tabular figures so digits line up. A few looks:
<!-- Compact / dense -->
<Grid {rows} {columns} rowHeight={28}
theme={{ fontSize: '12px', cellPad: '6px' }} height={640} />
<!-- Roomy & rounded -->
<Grid {rows} {columns} rowHeight={44}
theme={{ radius: '16px', cellPad: '16px', fontSize: '14px' }} height={640} />
<!-- Branded -->
<Grid {rows} {columns}
theme={{ bg: '#0b1020', headerBg: '#0d1226', up: '#22d3ee', down: '#fb7185',
selBorder: '#22d3ee', radius: '12px' }} height={640} />Server-side / large datasets
Instead of an in-memory rows array, back the grid with a RowSource — the
grid requests only the visible window (plus overscan), so the dataset can be far
larger than memory. Sort and filter are delegated to the source; unloaded rows
render as skeletons.
<script lang="ts">
import { Grid, createArraySource, type RowSource } from 'bo-grid';
// Your own source: fetch a window from the server.
const source: RowSource = {
async getRows({ range, sort, filter }) {
const res = await fetch(`/api/rows?offset=${range.start}&limit=${range.end - range.start}` +
`&sort=${sort?.key ?? ''}&dir=${sort?.dir ?? ''}&q=${filter}`);
return res.json(); // { rows, total }
},
};
</script>
<Grid {columns} {source} height={640} />createArraySource(rows, { latency, filterKeys }) adapts an in-memory array to
the same interface (handy for testing the path or client-side data). Grouping is
client-only, so it's not applied in source mode.
Wide grids (column virtualization)
Rows are virtualized vertically by default. For very wide grids (100+ columns),
add virtualizeColumns to also virtualize horizontally — only the columns in
the scroll window (+ overscan) render, so a 60-column grid costs about the same as
a handful. It switches the grid to fixed-width horizontal scroll; pinned columns
always render. See the Wide example.
<Grid {rows} {columns} virtualizeColumns height={520} />Column reorder
Pass onRowReorder(from, to) to enable drag-to-reorder rows via a handle in
the first column — reorder your own rows array in the callback (flat, unsorted,
in-memory lists). Drag any column header to reorder columns; pass persistKey to
remember the user's order across reloads (saved to localStorage):
<Grid {rows} {columns} persistKey="watchlist" height={640} />Column resize
Drag the grip on a header's right edge to resize a column; double-click the
grip to reset it to its default width. Resizing a fit-to-width (flex) column
pins it to the dragged width and lets its neighbours absorb the difference. The
same persistKey remembers widths across reloads.
Bound a column's draggable range with minWidth / maxWidth. Resizing is on by
default. Turn it off for the whole grid with resizable={false}, or per column
with resizable: false (handy for a fixed action column):
<Grid {rows} {columns} resizable={false} height={640} />Column visibility
Pass hiddenColumns (column keys to hide) — controlled, like filter. Build
your own column-picker UI and drive the prop; the grid stays presentation-only:
<Grid {rows} {columns} hiddenColumns={['bonus', 'rating']} height={640} />Or set columnMenu for a per-column header menu (a ⋮ trigger, or
Alt+↓ on the focused column) with sort, pin
(left/right/unpin), Autosize (fit to content), and Hide column actions,
and columnsPanel for a "Columns" button that opens a checklist to toggle
visibility (and restore hidden columns). Runtime hide/pin compose with
hiddenColumns / col.pinned, persist via persistKey, and hide reports through
onColumnVisibilityChange:
<Grid {rows} {columns} columnMenu height={640}
onColumnVisibilityChange={(hidden) => (myHidden = hidden)} />Header groups
Give columns a group label to render a spanning parent header over consecutive
columns that share it (works best with fixed-width columns):
const columns = [
{ type: 'text', key: 'symbol', header: 'Symbol', width: 120, group: 'Holding' },
{ type: 'number', key: 'shares', header: 'Shares', width: 90, group: 'Holding' },
{ type: 'price', key: 'last', header: 'Last', width: 90, group: 'Pricing' },
];Tree data
Pass getChildren to render hierarchical rows — rows become the roots, and each
node gets an indented first column with an expand chevron when it has children:
<Grid {rows} {columns} height={520} getChildren={(r) => r.children} />In tree mode the grid renders the tree directly (filter/sort/group/paginate are
not applied to it). Nodes are keyboard-accessible: → expands a collapsed node,
← collapses an expanded one, and rows expose aria-level / aria-expanded.
Lazy (server-backed) trees
For hierarchies too large to ship upfront, use loadChildren (async) instead
of getChildren. Children load on first expand — the grid shows a loading row,
then caches them. Pair it with hasChildren (a cheap predicate) so the chevron
shows without loading. See the Lazy tree example.
<Grid
{rows}
{columns}
height={520}
hasChildren={(r) => r.kind === 'folder'}
loadChildren={(r) => fetch(`/api/children/${r.id}`).then((res) => res.json())}
/>Master-detail
Pass a detail snippet to render an expandable panel under each row — the grid
adds a leading expand toggle and virtualizes the expanded heights (detailHeight,
default 160). In-memory mode:
<Grid {rows} {columns} height={640} detailHeight={120} detail={rowDetail} />
{#snippet rowDetail({ row })}
<div class="detail">…anything about {row.name}…</div>
{/snippet}Per-row styling
Return a class from rowClass to style rows by their data (e.g. red/green book
levels). Rows live inside the grid, so target the class with :global(...):
<Grid {rows} {columns} height={640} rowClass={(r) => (r.up ? 'gain' : 'loss')} />
<style>
:global(.bo-grid .row.gain) { color: var(--up); }
:global(.bo-grid .row.loss) { color: var(--down); }
</style>For per-column styling, a column's cellClass (static or (value, row)
conditional) and headerClass add classes to that column's cells/header:
{ type: 'number', key: 'pnl', header: 'P&L',
cellClass: (v) => (Number(v) < 0 ? 'loss' : 'gain'), headerClass: 'num-head' }onRowClick(row, event) fires when a row is activated by click or Enter
on the focused cell — wire it to open a detail view or navigate.
Pass rowMenu(row) to add a right-click menu of row actions; each item runs
its onSelect and the menu closes (also on outside-click or Esc). It
is keyboard-accessible: the ContextMenu key (or
Shift+F10) opens it at the focused cell.
<Grid {rows} {columns} height={640}
rowMenu={(r) => [{ label: 'Delete', onSelect: () => remove(r.id) }]} />Inline editing
Mark a column editable: true. Double-click a cell (or press Enter on
the focused cell) to edit; Enter/blur commits, Esc cancels.
Or just start typing — a printable key on a focused editable cell opens the
editor seeded with that character (Excel-style type-to-edit). The editor matches
the column type: numeric columns get a number input, date columns a native date
picker, options columns a <select>, everything else a text input. The grid is
controlled, so it reports the change via onCellEdit — update your own row data
there:
<Grid
{rows}
{columns}
height={640}
onCellEdit={(e) => (e.row[e.column.key] = e.value)}
/>e.value is parsed to a number for numeric columns (invalid input is rejected),
otherwise the raw string. Make the edited field $state so the cell updates. Add
a column validate(value, row) to reject edits that fail your own rule (it
applies to paste too):
{ type: 'number', key: 'qty', header: 'Qty', editable: true, validate: (v) => v >= 0 }Give an editable column options to edit it via a dropdown instead of a text
input (enum/status columns):
{ type: 'text', key: 'status', header: 'Status', editable: true,
options: ['New', 'Active', 'Closed'] }Pinned columns
Set pinned: true (or 'left') on a column to keep it visible while the rest
scroll horizontally; pinned: 'right' sticks it to the right edge (e.g. an
actions or total column). Opt-in: with no pinned columns the grid stays
fit-to-width (no horizontal scroll).
const columns = [
{ type: 'text', key: 'symbol', header: 'Symbol', width: 132, pinned: true },
{ type: 'price', key: 'price', header: 'Price', width: 88, pinned: true },
// …wider columns scroll under the pinned ones…
{ type: 'number', key: 'pnl', header: 'P&L', width: 96, pinned: 'right' },
];Export & import
CSV export — and import — are dependency-free:
import { exportCSV, toCSV, parseCSV } from 'bo-grid';
exportCSV('tickers.csv', rows, columns); // triggers a download
const text = toCSV(rows, columns, { formatted: true }); // or get the string
const rows = parseCSV(text, columns); // …and back to rows (round-trip)parseCSV is RFC4180-aware (quoted fields, embedded commas/quotes/newlines), maps
headers to columns, coerces numeric/date columns, and stamps id + flash fields
so the result drops straight into <Grid rows={…}>. There's also parseTSV (tab-
separated — what Ctrl/⌘+C copies), and for JSON/API data, rowsFromObjects(objects)
/ parseJSON(text):
import { rowsFromObjects } from 'bo-grid';
const rows = rowsFromObjects(await (await fetch('/api/rows')).json());Not sure what you'll get? parseRows(text, columns?) auto-detects JSON / TSV /
CSV — perfect for a paste handler:
<div onpaste={(e) => (rows = parseRows(e.clipboardData.getData('text'), columns))}>…</div>See the CSV import demo (CSV / TSV / JSON / Auto-detect).
Excel export loads SheetJS via dynamic import, so it lands in its own lazy
chunk and never bloats your core bundle. xlsx is an optional peer dependency
— install it only if you use this:
import { exportXLSX } from 'bo-grid';
await exportXLSX('tickers.xlsx', rows, columns); // npm i xlsxSparkline columns are skipped; numeric columns export as raw numbers so
spreadsheets can compute on them (pass { formatted: true } for display strings).
Ctrl/⌘+C still copies the current selection as TSV.
Printing. The grid virtualizes, so printing it directly drops off-screen rows.
printTable(rows, columns, { title }) opens a print window with all rows as a
clean table (Save-as-PDF from the dialog); toHTMLTable(rows, columns) returns
that table as an HTML string to embed. See the Print demo.
import { printTable } from 'bo-grid';
printTable(rows, columns, { title: 'Sales report' });Also exported
Sparkline component · drawCandles / setupHiDpiCanvas (draw on your own
canvas) · fmtPrice / fmtPercent / fmtVolume / fmtDate · heatColor ·
Selection · aggregate · toCSV / exportCSV / exportXLSX / rowsToMatrix ·
parseCSV / parseCSVMatrix / parseTSV / parseJSON / parseRows /
rowsFromObjects · toHTMLTable / printTable.
Pivot tables
pivot() transforms flat rows into a pivot table (rows + dynamic columns) you
hand straight to <Grid> — group by row fields, spread a field's values into
columns, and aggregate a measure into each cell:
<script lang="ts">
import { Grid, pivot } from 'bo-grid';
const { rows: pivotRows, columns: pivotColumns } = pivot(data, {
rowFields: ['sector'], // → leading text columns
columnField: 'exchange', // distinct values → columns (+ a Total)
measure: 'volume',
agg: 'sum',
});
</script>
<Grid rows={pivotRows} columns={pivotColumns} height={640} />It's a pure function, so call it as a snapshot or reactively as you prefer.
Accessibility
The grid follows the ARIA grid pattern. Because rows are virtualized, it exposes the real dimensions and positions so assistive tech isn't misled:
role="grid"witharia-rowcount/aria-colcount(full size, not the rendered window) andaria-multiselectable.role="row"+aria-rowindexon rows,role="gridcell"+aria-colindex+aria-selectedon cells,role="columnheader"+aria-sorton headers.aria-activedescendanttracks the focused cell for screen readers.- Sparkline cells carry a text
aria-label; sticky/skeleton duplicates arearia-hidden; the aggregation bar is anaria-livestatus region. - Fully keyboard-operable: one tab stop with arrow-key navigation, APG-pattern
menus (focus moves in, arrow-navigable, returns focus on close), a keyboard path
to filtering (column menu → Filter…), and visible
:focus-visiblerings on every reachable control. Respectsprefers-reduced-motion.
bo-grid targets WCAG 2.1 AA. See ACCESSIBILITY.md for the full keyboard map, roles, measured contrast ratios, and conformance notes from the audit.
Develop
pnpm install
pnpm dev # demo/playground at http://localhost:5180
pnpm test # unit tests (Vitest)
pnpm check # type-check
pnpm smoke # headless mount + interaction smoke test
pnpm size # bundle-size budget
pnpm package # build the publishable library into dist/Roadmap
Formal WCAG 2.1 AA audit → multi-measure pivots → more themes. Contributions welcome.
License
MIT
