Grids
The data grid is the table your application is built around: orders, customers, stock, tickets, trades. It binds to an array, a URL of JSON or CSV, a table already in your page, or your own server a block at a time — and a million rows scroll as smoothly as a hundred, because only the rows and columns in view are ever drawn.
Any data source: JavaScript arrays, REST and JSON APIs, CSV, TSV and JSON-lines files, a file the reader opens, an HTML table in the page, your own server a block at a time, and live WebSocket or server-sent events feeds — try them.
Fifty column types each know how to show, sort, filter and edit their values: money in the reader's currency and locale, dates that stay on their day in every time zone, status badges, progress bars, rings, stars, heat maps, data bars, trends, sparklines, people, pictures, tags, files, due dates, buttons and row actions.
Sort by several columns, search every column at once, filter with >100,
2026-09 or last 7 days, choose from a column's own values, and drag a heading to
the group bar to group — with totals in every group and the footer. It is editable unless you say
otherwise: every edit is validated, can be refused by your page, undone, and reported for saving.
And the rest of what business screens ask for: trees, master and detail rows, a pivot, rows dragged into order, a fill handle, a context menu, conditional formatting written as data, pinned rows, cards on a phone, pages, printing, Excel files in the grid's own colours, and a live feed over WebSocket or server-sent events.
The pivot grid is the other half of the family: a multi-dimensional, OLAP-style pivot worked out entirely in the browser. Any number of dimensions down the side and across the top, nested as hierarchies that open and close, any number of values in every cell, totalled on a background thread in well under a second for half a million records — with slicers, a pivot chart that follows it, conditional formatting, Excel export and drill-through. Try it.
Try it with your own files in confidence: they are opened by your browser, on your device. Nothing is uploaded, no copy reaches us or anyone else, and nothing is kept once you close this tab.
Any data source
The grid takes your data in whatever it already is, and reads the columns from it when you give none. Press a button to load the same grid from each: every one of them here is made up in this page, so nothing leaves it.
Every way in.
grid.rows = array for objects you already have;
<bmx-data-grid src="/api/orders"> or grid.load(url) for a REST or JSON API,
CSV, TSV or JSON lines (with your own headers through requestInit);
grid.load(file) or openFile() for a file the reader chooses or drops;
a <table> written inside the element (below);
dataProvider for your server a block at a time, with the sort, filters and search
(below); live-src for a WebSocket or server-sent events feed
(below); and tree-field or parent-field for tree data
(below). No adapter, no plug-in, and the same grid in every case.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-sources" label="Loaded data" group-panel="false" style="--bmx-data-grid-height: 22rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-sources');
dataGrid.rows = [ /* your rows */ ];
</script>
An order book, twenty thousand rows
A year of orders made up in this page. Click a heading to sort and Shift+click to sort by another as well; drag a heading onto the bar above the grid to group by it; open a heading's menu to filter by its values, pin it, hide it or fit it. Type in the search. Double-click a cell, or just start typing, to change it; drag across cells, or use Shift and the arrows, to see their total at the bottom.
One tag, rows in and changes out. grid.columns = [{ field: 'total', type: 'currency', currency: 'GBP', aggregate: 'sum' }, ...]
and grid.rows = orders. Edits are written into your own objects, and
await grid.getChanges() gives what was added, changed and removed since loading — send
it to your server and call acceptChanges(). The view the reader arranged is kept in this
browser under state-key; getState() gives it to you to keep anywhere else.
After a save, Ctrl+Z still steps back, and what it undoes is reported for saving
again. The printer in the toolbar prints every row the filters keep, headings on each page; the Excel file
keeps the grid's colours, groups as outline levels and the headings frozen.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-orders" label="Orders" checkboxes row-numbers state-key="showcase-orders" file-name="orders" style="--bmx-data-grid-height: 38rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-orders');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
Fifty column types
Every type on one grid, grouped under headings: text, links, numbers, dates, choices, visual data, media and actions. Scroll sideways; the name column stays pinned. Sort any column to see it sorted by value — priorities from critical down, statuses in their workflow's order — and open a column's menu to see the filter that suits it.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-types" label="Every column type" toolbar="false" group-panel="false" style="--bmx-data-grid-height: 30rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-types');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
Edit, validate, undo, save
A small stock list with rules: every product needs a name and a code of three letters and four digits, stock cannot go below zero, and the page itself refuses a price under cost. Try breaking them. Changed cells are marked in the corner until the changes are saved; paste a block from a spreadsheet, or add a row with the + in the toolbar.
Your rules, your say. Columns take required, min,
max, pattern, maxLength and a validate function; the
editor stays open and says why until the value is acceptable. bmxGridCellChange is
cancelable, so your page has the last word on every change — typed, pasted, cleared, undone.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-stock" label="Stock" group-panel="false" style="--bmx-data-grid-height: 22rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-stock');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
Trees: an organisation and its budget
Rows that hold rows. Each row here names its parent's key (parent-field); data that
carries its children in an array takes tree-field instead. Click a chevron, or use the right
and left arrows on a name. Search for a team: it is shown where it lives, with every level above it
opened. Sort any column and each level sorts among itself.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-tree" label="Organisation" parent-field="parent" style="--bmx-data-grid-height: 24rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-tree');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
Master and detail
Every order opens to show its lines, in a grid of their own. Click the expander, or press
Space on it, and Enter on the panel to move into it. The panel keeps what it holds
while you scroll away and back. Give detailRenderer a function instead and the panel holds
anything you like — a chart, a form, another component.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-detail" label="Orders with their lines" group-panel="false" detail-height="230" style="--bmx-data-grid-height: 28rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-detail');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
Pivot: rows into totals
A year of sales, pivoted. Press Pivot in the toolbar to choose your own rows, columns and totals; the search and filters still apply to the sales beneath. What comes out is an ordinary grid: sort it, export it, print it.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-pivot" label="Sales" allow-pivot group-panel="false" file-name="sales" style="--bmx-data-grid-height: 26rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-pivot');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
A plan you can work in
A launch plan that behaves like a spreadsheet. Choose two cells and drag the square at their corner: numbers, dates and Week 3 carry on. Drag a row by its handle, or press Alt+Shift with an arrow, to move it. Right-click a cell, or press Shift+F10, for its commands. The milestone stays pinned at the top; late tasks turn red and finished ones are struck through, by rules written as data.
Rules as data.
format-rules='[{ "field": "due", "when": { "op": "lt", "value": "today" }, "style": { "tone": "danger" } }]'
— the filters' own tests, so a rule can live in a database or a server page and read the way the
filter that finds the same rows would. Cells can also span: a column's colSpan(row) joins
a cell to the ones after it, as the milestone's title does here.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-plan" label="Launch plan" row-reorder pinned-rows="M1" group-panel="false" style="--bmx-data-grid-height: 24rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-plan');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
Cards on a phone, pages when you want them
Narrower than 600 pixels, the grid shows each row as a card with the fields you choose, and it happens by
itself; here you can switch. And for readers who prefer pages to one long scroll, pagination
adds a pager that the sort, filters and search all respect.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-customers" label="Customers" pagination page-size="10" card-fields="name company status spend renews" group-panel="false" style="--bmx-data-grid-height: 30rem; max-width: 60rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-customers');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
Live prices
Point live-src at a WebSocket or server-sent events URL and each message updates the rows:
an array of rows, matched by rowKey, or { "op": "remove", "keys": [...] }. A
feed of your own goes through applyLiveMessage(), as this made-up one does. Each changed
cell flashes: green up, red down. Sorting, filters and totals keep up, and the connection comes back by
itself when it drops.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-live" label="Prices" readonly toolbar="false" group-panel="false" status-bar="false" appearance="minimal" style="--bmx-data-grid-height: 22rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-live');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
A million rows from a server
Some data is too big to send to the page. Here dataProvider is a stand-in server that takes
a quarter of a second to answer: the grid asks it for a hundred rows at a time as you scroll, with the
sort, filters and search, and shows the rows still on their way as placeholders. Drag the scrollbar to
the middle and watch only the rows you land on being asked for.
Wiring it to your API.
grid.dataProvider = async ({ start, count, sort, filters, search, signal }) => (await fetch(url(start, count, sort, filters, search), { signal })).json()
— answer { rows, total }. A request the reader has scrolled past is aborted. This
stand-in sorts by the transaction number and searches by reference; yours applies all of it.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-server" label="Transactions from the server" readonly group-panel="false" style="--bmx-data-grid-height: 26rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-server');
dataGrid.columns = [ /* your columns */ ];
dataGrid.dataProvider = async ({ start, count, sort, filters, search }) => { /* one block from your server */ };
</script>
A million rows in the page
Press the button to give the grid a million rows at once. Scroll, sort by any column, search and group: the rows are never copied, sorting ranks each column's values once, and only what is in view is drawn.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-large" label="A million rows" group-panel="false" empty-text="Press the button to load a million rows." style="--bmx-data-grid-height: 26rem"></bmx-data-grid>
<script>
const dataGrid = document.getElementById('gr-large');
dataGrid.columns = [ /* your columns */ ];
dataGrid.rows = [ /* your rows */ ];
</script>
A plain HTML table, made a grid
Wrap a table your server already renders. The grid reads its headings and cells once —
data-type on a heading says what the column is, data-value on a cell gives the
value behind formatted text — and where script never runs, the table is still a table.
| Office | Staff | Revenue | Opened | Status |
|---|---|---|---|---|
| York | 42 | $1.84m | March 2019 | Open |
| Leeds | 118 | $6.12m | September 2014 | Open |
| Manchester | 86 | $4.41m | January 2016 | Open |
| Bristol | 0 | — | February 2027 | Planned |
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-data-grid id="gr-table" label="Offices" toolbar="false" group-panel="false" status-bar="false" style="--bmx-data-grid-height: 17rem">
<table>
<thead>
<tr><th>Office</th><th data-type="integer">Staff</th><th data-type="currency">Revenue</th><th data-type="date">Opened</th><th data-type="status">Status</th></tr>
</thead>
<tbody>
<tr><td>York</td><td>42</td><td data-value="1840000">$1.84m</td><td data-value="2019-03-04">March 2019</td><td>Open</td></tr>
<tr><td>Leeds</td><td>118</td><td data-value="6120000">$6.12m</td><td data-value="2014-09-15">September 2014</td><td>Open</td></tr>
<tr><td>Manchester</td><td>86</td><td data-value="4410000">$4.41m</td><td data-value="2016-01-11">January 2016</td><td>Open</td></tr>
<tr><td>Bristol</td><td>0</td><td data-value="0">—</td><td data-value="2027-02-01">February 2027</td><td>Planned</td></tr>
</tbody>
</table>
</bmx-data-grid>
The pivot grid: sales in every dimension
A hundred thousand sales, made up in this page, pivoted by region and country down the side and by year and quarter across the top. Open the field list with its tool to drag fields between Filters, Columns, Rows and Values; click a heading at the top left to sort, filter or keep the top few; open and close members with their arrows or the arrow keys; double-click any total to see the records behind it.
Nothing is sent anywhere. The records are read once into typed columns and totalled in a
single pass on a background thread the library starts itself; only the cells in view are drawn, both ways.
The layout is plain JSON — getState() and setState() save and restore a
reader's whole workspace.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-pivot-grid id="pv-sales" label="Sales" field-list="closed" file-name="sales" style="--bmx-pivot-grid-height: 34rem"></bmx-pivot-grid>
<script>
const pivotGrid = document.getElementById('pv-sales');
pivotGrid.data = [ /* your data */ ];
</script>
Slicers and a chart that follow the pivot
<bmx-pivot-slicer> filters a pivot by any field, placed on it or not, and shows each
member's records under the other slicers' choices. <bmx-pivot-chart> draws the
pivot's totals and redraws as it changes. Both are separate elements, themed like the rest, pointed at
the pivot with for.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-pivot-slicer for="pv-sliced" field="region"></bmx-pivot-slicer>
<bmx-pivot-slicer for="pv-sliced" field="category" layout="list"></bmx-pivot-slicer>
<bmx-pivot-slicer for="pv-sliced" field="channel" hide-empty></bmx-pivot-slicer>
<bmx-pivot-slicer for="pv-sliced" field="ordered:year" label="Year" layout="list" multiple="false"></bmx-pivot-slicer>
<bmx-pivot-chart id="pv-chart" for="pv-sliced" label="Revenue by month" style="--bmx-pivot-chart-height: 18rem"></bmx-pivot-chart>
<bmx-pivot-grid id="pv-sliced" label="Sales by month" field-list="none" style="--bmx-pivot-grid-height: 22rem"></bmx-pivot-grid>
<script>
const pivotGrid = document.getElementById('pv-sliced');
pivotGrid.data = [ /* your data */ ];
</script>
Conditional formatting, Excel and print
Rules are data — data bars, colour scales, arrows and traffic lights, the top or bottom few, above or below average — and the reader can add them from any value's menu. The Excel file is written by the library itself: headings merged, rows as an outline, headings frozen, and the same rules as Excel's own, so they still work when the numbers change.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-pivot-grid id="pv-formatted" label="Margin by rep and month" field-list="none" file-name="margin-by-rep" style="--bmx-pivot-grid-height: 26rem"></bmx-pivot-grid>
<script>
const pivotGrid = document.getElementById('pv-formatted');
pivotGrid.data = [ /* your data */ ];
</script>
Totals from your own server
When the records are too many to send, give the pivot a dataProvider. It asks for the levels,
values and filters of the current layout; your server answers every cell with its subtotals — one
GROUP BY ROLLUP — and sorting, top N, shares and formulas still work in the page. Here
the "server" is a function in this page that takes a third of a second.
Your server, your protocol.
pivot.dataProvider = async ({ rows, columns, values, filters, signal }) => (await fetch(url, { method: 'POST', body: JSON.stringify({ rows, columns, values, filters }), signal })).json()
— answer { cells: [{ rows, columns, values }] }. Nothing is hosted by the library;
memberProvider and drillProvider fill the filters and the drill-through panel.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-pivot-grid id="pv-remote" label="Sales from the server" field-list="closed" style="--bmx-pivot-grid-height: 22rem"></bmx-pivot-grid>
<script>
const pivotGrid = document.getElementById('pv-remote');
pivotGrid.dataProvider = async ({ start, count, sort, filters, search }) => { /* one block from your server */ };
pivotGrid.data = [ /* your data */ ];
</script>
Half a million records, totalled in the page
Press the button to give a pivot half a million records with three levels down the side and one across. Then change the layout: every change is a fresh pass over all of them.
Get the code
<script src="/assets/bmx-components.min.js"></script>
<bmx-pivot-grid id="pv-large" label="Half a million records" field-list="closed" style="--bmx-pivot-grid-height: 24rem"></bmx-pivot-grid>
<script>
const pivotGrid = document.getElementById('pv-large');
pivotGrid.data = [ /* your data */ ];
</script>