Component
Vue Data Grid with Filtering
<DomDataGrid>A dense spreadsheet-like grid for database records, with typed column filters, sorting, row selection, and a server-query friendly API. We talk about databases, rendering pipelines, virtual DOMs, and scalable app architecture. But really, they just want Excel.
Quick start
Use DomDataGrid now
Choose the installation path that suits this stage
Use npm for the fastest working version. Copy the source when you want DomDataGrid to become part of your codebase: yours to edit, adapt, and maintain.
When to use it
Use DomDataGrid for tables of records people scan, sort, filter, select and edit: admin lists, database tables, ledgers, logs and spreadsheet-style screens. It handles virtual scrolling, typed filters, sorting, row selection, inline editing, range copy and paste, grouping with totals, pinned columns and CSV or Excel export.
The grid never changes your data. It renders the rows you pass, reports what the person did through events, and leaves saving to you.
Quick start
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomDataGrid } from '@getdom/studio';
const rows = ref([
{ id: 1, customer: 'Atlas Works', status: 'paid', amount: 1234.5, orderedAt: '2026-03-14' },
{ id: 2, customer: 'Northstar Labs', status: 'pending', amount: 89, orderedAt: '2026-03-15' },
]);
const columns = [
{ key: 'id', label: 'Order #', type: 'number', width: 96 },
{ key: 'customer', label: 'Customer' },
{ key: 'status', label: 'Status', type: 'select', options: ['pending', 'paid', 'shipped'] },
{ key: 'amount', label: 'Amount', type: 'currency', currency: 'USD', locale: 'en-US' },
{ key: 'orderedAt', label: 'Ordered', type: 'date' },
];
</script>
<template>
<DomDataGrid :rows="rows" :columns="columns" title="Orders" resource-label="orders" />
</template>The grid uses DOM Studio's styles and one custom element for its popovers. Set both up once per app, as the package README describes: import @getdom/studio/style.css (or @getdom/studio/tailwind.css from your own Tailwind stylesheet), and tell Vue that tags starting with dom- are custom elements in vite.config.js:
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('dom-') } } })Defaults to check first
The grid starts as a full spreadsheet. Turn off what a simple list doesn't need.
| Prop | Default | Change it when |
|---|---|---|
selectable |
true |
Rows can't be selected: :selectable="false". |
showRowNumbers |
true |
You don't want the 1, 2, 3 column: :show-row-numbers="false". |
showColumnLetters |
true |
You don't want the A, B, C row above the headers: :show-column-letters="false". |
resizableColumns |
true |
Columns should keep fixed widths. |
toolbar |
true |
You render your own title, search and counts: :toolbar="false". |
title |
'Data grid' |
Always set it, or hide the toolbar. |
height |
'34rem' |
This is a maximum height for the scrolling body. Use a CSS size or pixels. |
density |
'compact' |
Rows should breathe: density="comfortable". |
column currency |
'GBP' |
Set currency on every currency column, such as 'USD'. |
column locale |
'en-GB' |
Set locale, such as 'en-US', for US dates and number formats. |
Currency cells drop the minor units at 1,000 and above (£1,235). Pass format and formatAggregate when you need exact amounts such as $1,234.50.
Columns
Each column is a plain object. key is required and is also the row property to read unless you set field or accessor. The column type decides sorting, formatting, alignment, the filter control and the editor.
type |
Sorts as | Filter control | Editor | Default width and alignment |
|---|---|---|---|---|
text |
Case-insensitive text | Contains text | Text input | 13rem, left |
number |
Number | Min and max | Number input | 7rem, right |
currency |
Number | Min and max | Number input | 8.5rem, right |
date |
Date | Date range | Date input | 9.5rem, left |
boolean |
false before true | Yes or no | Toggle on Space or double-click | 7rem, center |
select |
Option value as text | Checklist of options | Select list | 9rem, left |
Useful column options:
optionson select columns: strings or{ label, value, tone }objects.tone(green, amber, red, blue) colours the status pill. Always supply options on large data sets; otherwise the grid scans every row to find them.format(value, row, column)returns the display text. Global search matches this text, not the raw value.accessor(row, column)computes a value, such asquantity * price. Columns with an accessor are read-only.sortAccessor(row, column)sorts by something other than the cell value.sortable: false,filterable: false,searchable: false,resizable: false,exportable: falseandhidden: trueturn features off per column.pinned: 'left'or'right'keeps the column in view while the grid scrolls sideways.componentrenders every cell with your Vue component. It receivesrow,column,value,formatted,rowIndexandrowKey, can emitupdate:valueto change the cell andactionto send a command, which the grid re-emits ascell-action.
Store dates as YYYY-MM-DD strings or Date objects. Strings in that form sort, filter and format correctly and are the cheapest to sort.
Sorting, search and filters
Clicking a sortable header cycles ascending, descending, then unsorted. With multi-sort, each header click adds or changes a rule in sort.rules, which a server can translate into a multi-column ORDER BY.
Sorting, search and filters are controlled props, so you can set them from your own controls and keep them in the URL or a store:
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
</script>
<DomDataGrid
v-model:sort="sort"
v-model:search="search"
v-model:filters="filters"
:rows="rows"
:columns="columns"
/>sortis{ key, direction }with direction'asc','desc'or'', or{ rules: [...] }.searchmatches the formatted text of every searchable column.filtersis keyed by column key. Select columns take{ type: 'select', values: ['paid', 'shipped'] }, number and currency columns{ min, max }, date columns{ start, end }, boolean columns{ value: true }, and text columns{ value: 'atlas', operator: 'contains' }with operator'contains','starts'or'equals'.
Filters applied from your own controls also show as chips in the toolbar and on the column header, and "Clear filters" clears them.
Editing
Editing takes two steps: let the cells be edited, then write each change back to your rows.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
function applyChange({ row, column, value }) {
row[column.field || column.key] = value;
}
</script>
<template>
<DomDataGrid :rows="rows" :columns="columns" editable cell-selection @cell-update="applyChange" />
</template>editablelets every plain column be edited. Seteditable: falseon a column to lock it, oreditable: trueto allow just that column. A functioneditable(row, column)decides per cell.- People double-click a cell to edit it. Add
cell-selectionfor keyboard editing: the grid body becomes one Tab stop, the first arrow key picks a cell, arrow keys move between cells, Enter, F2 or typing starts an edit, Enter saves and moves down, Tab saves and moves right, leaving the cell saves, and Escape cancels. - Select columns edit with a native select that includes an empty "-" choice. Add
validateif the value is required. cell-selectionalso adds range selection, copy, cut, paste from Excel or Google Sheets, Delete to clear, and undo or redo.validate(value, row, column)returnstrueor an error message, which is shown and keeps the old value. Add a check for empty text if a column must not be blank.parse(text, column)converts typed or pasted text before validation.cell-updatefires once per changed cell with{ row, column, rowIndex, rowKey, value, previousValue, source }.cells-updatefires once per edit, paste, delete, undo or redo with every change, so you can save a batch in one request.
Edits change your rows, and sorting, grouping, filters and totals follow, because the grid reads from them.
Selection
Rows have checkboxes by default. Bind v-model:selected-keys to read or set the selection. Keys come from row-key, which defaults to the id field; pass a field name or a function when your rows use another key.
The header checkbox selects or clears every row that matches the current search and filters, including rows that are scrolled out of view.
Grouping and totals
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
</script>
<DomDataGrid v-model:group-by="groupBy" :rows="rows" :columns="columns" show-totals column-menu />group-bytakes a column key, or an array of keys for nested groups. Each group row shows its value, a row count and the aggregate of every column that setsaggregate.aggregateis'sum','avg','count','min','max'or a function(values, rows, column).show-totalsadds a totals row pinned to the bottom with the same aggregates over all filtered rows.groups-expandedsets whether groups start open. The template ref methodsexpandAllGroups()andcollapseAllGroups()open or close all of them.column-menuadds a menu to every header so people can group by or pin any column themselves.
Export
exportable adds an Export menu to the toolbar with CSV and Excel (.xlsx) downloads. Exports contain the filtered, sorted rows with typed numbers, dates and currency. To export from your own button, call exportCsv() or exportExcel() from the toolbar-actions slot props or a template ref:
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
</script>
<DomDataGrid ref="grid" :rows="rows" :columns="columns" export-file-name="orders" />
<button type="button" @click="grid.exportCsv()">Export CSV</button>Use exportable: false to leave a column out, and exportValue(value, row, column) to change what is exported and copied.
Large data sets
Add virtual-rows for in-memory data sets above a few hundred rows. Only the rows in view, plus virtual-row-buffer rows above and below, are mounted, and search, filters, sorting, grouping and selection still cover every row. Rows have a fixed height from the density; set virtual-row-height if your custom cells are taller.
For speed on large sets, supply options on select columns and keep dates as YYYY-MM-DD strings.
Server data
Set remote when a server does the searching, filtering, sorting and paging. The grid then renders rows exactly as given and fires query-change with { search, filters, sort } whenever the person changes them.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
</script>
<DomDataGrid
:rows="page.rows"
:columns="columns"
:total-rows="page.total"
:loading="loading"
remote
@query-change="load"
@viewport-end="loadMore"
/>total-rows sets the count in the toolbar. viewport-end fires near the bottom of the scroll area for cursor-based loading.
A resource API can take the query as it comes, with paging beside it. The same endpoint can serve numbered pages and cursor-based scrolling:
// Request
{
resource: 'customers',
query: {
search: 'atlas',
filters: {
status: { type: 'select', values: ['active', 'trial'] },
arr: { type: 'currency', min: 50000, max: 250000 },
},
sort: { key: 'updatedAt', direction: 'desc', rules: [{ key: 'updatedAt', direction: 'desc' }, { key: 'arr', direction: 'desc' }] },
},
pagination: { strategy: 'page', page: 2, pageSize: 25 }, // or { strategy: 'cursor', cursor: 'customer:25', limit: 25 }
}
// Response
{
data: [/* rows */],
pageInfo: { strategy: 'page', page: 2, pageSize: 25, pageCount: 8, total: 184, hasNextPage: true, nextCursor: 'customer:50' },
}Toolbar and footer
With toolbar on, the grid shows the title, a search box, filter chips with "Clear filters", the toolbar-actions slot, the Export menu when exportable is set, and a count such as "1,243 of 10,000 orders" using resource-label. The footer shows the column count, "N selected" when rows are selected, and short notices such as "Exported 1243 rows to CSV". Set :toolbar="false" to supply your own.
Layout
- Drag a header divider to resize a column; right-pinned columns resize from their left edge. Resizing starts from the displayed width and keeps the other column widths steady. Spare space remains empty instead of stretching the columns, and
min-column-widthlimits how far a column can shrink. layout="flush"removes the outer border, rounding and shadow for edge-to-edge pages;gutter-classaligns the outer cell padding with your page gutter.- Narrow screens scroll the table sideways inside its frame, and pinned columns unpin themselves when they would leave too little room to scroll.
- Use the
cellslot for one-off cell rendering,toolbar-actionsfor buttons next to search, andemptyfor a custom empty state.
Examples
Trading desk: edit, group, pin and export
2,500 positions grouped by desk, with Ticker pinned left and P&L pinned right. Click a cell and use the arrow keys, drag a range to see its sum, double-click or type to edit, paste from a spreadsheet, and export the current view to Excel.
Trading desk positions
2,500 of 2,500 positions
| A | B | C | D | E | F | G | H | I | J | K | L | M | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| # | Ticker Pin column Filter Ticker | Instrument Pin column Filter Instrument | Desk Pin column Filter Desk Values | Region Pin column Filter Region Values | Quantity Pin column Filter Quantity | Price Pin column Filter Price | Market value Pin column Filter Market value | Day % Pin column Filter Day % | Status Pin column Filter Status Values | Trader Pin column Filter Trader | Trade date Pin column Filter Trade date | Settled Pin column Filter Settled | P&L Pin column Filter P&L | |
| 2,531,604 | $255.38 | $652,166,059 | +0.12% | 28 Sept 2026 | 500 | $1,006,273 | ||||||||
| 1 | CO1004 | Gold spot | Commodities | Americas | 3,670 | $31.14 | $114,284 | +0.23% | Open | T. Grant | 08 Aug 2026 | Yes | $263.00 | |
| 2 | CO1009 | Nat gas Jan | Commodities | Americas | 8,276 | $267.96 | $2,217,637 | -1.54% | Open | M. Okafor | 25 Sept 2026 | Yes | -$34,152 | |
| 3 | CO1014 | Nat gas Jan | Commodities | EMEA | 3,023 | $88.26 | $266,810 | +3.65% | Review | L. Chen | 04 Aug 2026 | Yes | $9,739 | |
| 4 | CO1019 | Nat gas Jan | Commodities | Americas | 7,467 | $210.07 | $1,568,593 | +3.66% | Hedged | T. Grant | 08 Jul 2026 | Yes | $57,410 | |
| 5 | CO1024 | Brent Dec | Commodities | APAC | 3,400 | $442.48 | $1,504,432 | +3.70% | Open | T. Grant | 14 Aug 2026 | Yes | $55,664 | |
| 6 | CO1029 | Nat gas Jan | Commodities | APAC | 8,121 | $138.04 | $1,121,023 | -2.28% | Hedged | R. Patel | 18 Aug 2026 | Yes | -$25,559 | |
| 7 | CO1034 | Gold spot | Commodities | Americas | 2,759 | $341.41 | $941,950 | -1.21% | Open | L. Chen | 12 Aug 2026 | Yes | -$11,398 | |
| 8 | CO1039 | Wheat Mar | Commodities | EMEA | 5,633 | $389.17 | $2,192,195 | +2.46% | Breach | M. Okafor | 11 Jul 2026 | No | $53,928 | |
| 9 | CO1044 | Brent Dec | Commodities | Americas | 4,972 | $76.92 | $382,446 | +3.05% | Breach | N. Ellis | 02 Aug 2026 | Yes | $11,665 | |
| 10 | CO1049 | Wheat Mar | Commodities | Americas | 7,854 | $266.08 | $2,089,792 | +1.47% | Open | S. Rivera | 14 Jul 2026 | Yes | $30,720 | |
| 11 | CO1054 | Nat gas Jan | Commodities | Americas | 8,044 | $373.06 | $3,000,895 | +3.75% | Open | L. Chen | 12 Aug 2026 | Yes | $112,534 | |
| 12 | CO1059 | Nat gas Jan | Commodities | Americas | 6,867 | $25.90 | $177,855 | +0.08% | Breach | S. Rivera | 17 Jul 2026 | Yes | $142.00 | |
| 13 | CO1064 | Wheat Mar | Commodities | EMEA | 174 | $127.31 | $22,152 | +1.96% | Open | S. Rivera | 25 Aug 2026 | Yes | $434.00 | |
| 14 | CO1069 | Nat gas Jan | Commodities | EMEA | 4,792 | $163.37 | $782,869 | -1.28% | Open | A. Hart | 22 Sept 2026 | No | -$10,021 | |
| 15 | CO1074 | Wheat Mar | Commodities | Americas | 1,556 | $337.31 | $524,854 | -3.72% | Review | L. Chen | 19 Aug 2026 | Yes | -$19,525 | |
| 16 | CO1079 | Wheat Mar | Commodities | APAC | 6,206 | $426.32 | $2,645,742 | -1.26% | Open | A. Hart | 15 Jul 2026 | Yes | -$33,336 | |
| 17 | CO1084 | Brent Dec | Commodities | EMEA | 7,619 | $498.42 | $3,797,462 | -0.64% | Breach | T. Grant | 07 Jul 2026 | Yes | -$24,304 | |
| 18 | CO1089 | Gold spot | Commodities | EMEA | 8,412 | $80.64 | $678,344 | -3.31% | Hedged | N. Ellis | 21 Aug 2026 | No | -$22,453 | |
| 19 | CO1094 | Copper 3M | Commodities | APAC | 2,647 | $157.37 | $416,558 | +0.88% | Open | R. Patel | 22 Sept 2026 | Yes | $3,666 | |
| 20 | CO1099 | Copper 3M | Commodities | APAC | 1,041 | $257.53 | $268,089 | -1.12% | Open | N. Ellis | 04 Aug 2026 | No | -$3,003 | |
| 21 | CO1104 | Nat gas Jan | Commodities | Americas | 2,361 | $224.09 | $529,076 | +2.23% | Open | A. Hart | 07 Sept 2026 | No | $11,798 | |
| 22 | CO1109 | Gold spot | Commodities | EMEA | 51 | $120.55 | $6,148 | -1.33% | Open | M. Okafor | 03 Aug 2026 | Yes | -$82.00 | |
| 23 | CO1114 | Copper 3M | Commodities | EMEA | 369 | $457.55 | $168,836 | +1.98% | Open | P. Singh | 14 Aug 2026 | Yes | $3,343 | |
| 24 | CO1119 | Gold spot | Commodities | APAC | 4,106 | $28.47 | $116,898 | +3.87% | Hedged | P. Singh | 21 Aug 2026 | Yes | $4,524 | |
| 25 | CO1124 | Wheat Mar | Commodities | EMEA | 8,632 | $393.11 | $3,393,326 | +0.83% | Review | M. Okafor | 15 Sept 2026 | No | $28,165 | |
| 26 | CO1129 | Gold spot | Commodities | EMEA | 3,705 | $277.31 | $1,027,434 | +2.54% | Review | R. Patel | 17 Aug 2026 | Yes | $26,097 | |
| 27 | CO1134 | Nat gas Jan | Commodities | EMEA | 7,541 | $405.48 | $3,057,725 | +2.74% | Review | N. Ellis | 26 Aug 2026 | Yes | $83,782 | |
| 28 | CO1139 | Copper 3M | Commodities | APAC | 8,465 | $450.49 | $3,813,398 | +2.92% | Open | N. Ellis | 25 Jul 2026 | Yes | $111,351 | |
| 29 | CO1144 | Wheat Mar | Commodities | Americas | 4,838 | $158.83 | $768,420 | -3.98% | Open | R. Patel | 17 Jul 2026 | No | -$30,583 | |
| 30 | CO1149 | Nat gas Jan | Commodities | APAC | 8,829 | $94.31 | $832,663 | -1.52% | Open | S. Rivera | 04 Aug 2026 | Yes | -$12,656 | |
| 31 | CO1154 | Brent Dec | Commodities | APAC | 8,428 | $454.11 | $3,827,239 | +2.61% | Breach | P. Singh | 12 Sept 2026 | Yes | $99,891 | |
| 32 | CO1159 | Gold spot | Commodities | Americas | 6,768 | $484.02 | $3,275,847 | -1.64% | Open | R. Patel | 28 Sept 2026 | Yes | -$53,724 | |
| 33 | CO1164 | Wheat Mar | Commodities | EMEA | 8,698 | $219.63 | $1,910,342 | -0.11% | Open | S. Rivera | 26 Jul 2026 | Yes | -$2,101 | |
| 34 | CO1169 | Nat gas Jan | Commodities | EMEA | 1,848 | $13.05 | $24,116 | -1.80% | Breach | S. Rivera | 08 Aug 2026 | Yes | -$434.00 | |
| 35 | CO1174 | Brent Dec | Commodities | APAC | 9,065 | $232.99 | $2,112,054 | +3.40% | Hedged | P. Singh | 11 Aug 2026 | Yes | $71,810 | |
| 36 | CO1179 | Brent Dec | Commodities | Americas | 5,222 | $24.89 | $129,976 | -1.59% | Open | R. Patel | 18 Sept 2026 | Yes | -$2,067 | |
| 37 | CO1184 | Nat gas Jan | Commodities | EMEA | 6,595 | $126.13 | $831,827 | +2.78% | Open | L. Chen | 17 Jul 2026 | Yes | $23,125 | |
| 38 | CO1189 | Gold spot | Commodities | Americas | 7,909 | $473.76 | $3,746,968 | +3.69% | Hedged | N. Ellis | 14 Aug 2026 | No | $138,263 | |
| 39 | CO1194 | Gold spot | Commodities | EMEA | 7,542 | $378.16 | $2,852,083 | -2.27% | Open | T. Grant | 09 Jul 2026 | Yes | -$64,742 | |
| 40 | CO1199 | Gold spot | Commodities | Americas | 8,677 | $458.45 | $3,977,971 | -3.03% | Open | N. Ellis | 13 Sept 2026 | Yes | -$120,533 | |
| 41 | CO1204 | Nat gas Jan | Commodities | APAC | 3,380 | $96.08 | $324,750 | -0.36% | Review | A. Hart | 11 Jul 2026 | Yes | -$1,169 | |
| 42 | CO1209 | Copper 3M | Commodities | APAC | 8,480 | $207.89 | $1,762,907 | +1.41% | Hedged | L. Chen | 21 Aug 2026 | Yes | $24,857 | |
| 43 | CO1214 | Wheat Mar | Commodities | EMEA | 1,395 | $189.19 | $263,920 | +3.32% | Open | P. Singh | 13 Sept 2026 | Yes | $8,762 | |
| 44 | CO1219 | Brent Dec | Commodities | Americas | 1,121 | $401.91 | $450,541 | -0.73% | Open | N. Ellis | 10 Jul 2026 | Yes | -$3,289 | |
| 45 | CO1224 | Nat gas Jan | Commodities | APAC | 2,793 | $283.24 | $791,089 | -2.35% | Open | P. Singh | 11 Sept 2026 | No | -$18,591 | |
| 46 | CO1229 | Brent Dec | Commodities | EMEA | 2,582 | $161.50 | $416,993 | +2.17% | Hedged | A. Hart | 03 Jul 2026 | No | $9,049 | |
| 47 | CO1234 | Gold spot | Commodities | APAC | 6,255 | $466.49 | $2,917,895 | -1.95% | Open | N. Ellis | 16 Aug 2026 | Yes | -$56,899 | |
| 48 | CO1239 | Brent Dec | Commodities | EMEA | 3,698 | $254.55 | $941,326 | -3.71% | Open | L. Chen | 03 Sept 2026 | No | -$34,923 | |
| 49 | CO1244 | Brent Dec | Commodities | APAC | 8,295 | $40.74 | $337,938 | +0.51% | Hedged | A. Hart | 13 Sept 2026 | Yes | $1,723 | |
| 50 | CO1249 | Copper 3M | Commodities | EMEA | 9,771 | $437.66 | $4,276,376 | -0.46% | Open | R. Patel | 15 Aug 2026 | No | -$19,671 | |
| Σ | Total | 12,660,189 | $252.42 | $3,198,811,590 | +0.07% | 28 Sept 2026 | 2,500 | -$1,387,689 |
Examples
Database-style account table
Column filters use typed controls: date columns open a date range, number columns use min and max, and select columns use checklists. Applied filters show in both the header and the toolbar.
Customer accounts
15 of 24 accounts
| A | B | C | D | E | F | G | H | I | J | K | L | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| # | Filter Account | Filter Status Values | Filter Plan Values | Filter Owner | Filter Seats | Filter ARR | Filter Health Values | Filter Region Values | Filter Renewal | Filter Active users | Filter Verified | Filter Updated | |
| 1 | Anchor Cloud | Active | Enterprise | Sofia Jensen | 264 | £310,000 | Strong | NA | 31 Jul 2026 | 240 | Yes | 11 Jun 2026 | |
| 2 | Orchid Health | Active | Enterprise | Laura Chen | 232 | £268,000 | Strong | NA | 22 Sept 2026 | 211 | Yes | 11 Jun 2026 | |
| 3 | Forge Robotics | Active | Enterprise | Priya Singh | 188 | £224,500 | Strong | APAC | 04 Sept 2026 | 169 | Yes | 10 Jun 2026 | |
| 4 | Mercury Media | Active | Enterprise | Caleb Stone | 176 | £201,300 | Strong | NA | 09 Jul 2026 | 151 | Yes | 09 Jun 2026 | |
| 5 | Northstar Analytics | Active | Enterprise | Amelia Hart | 142 | £184,000 | Strong | EMEA | 19 Aug 2026 | 118 | Yes | 10 Jun 2026 | |
| 6 | Signal Harbor | Active | Scale | Nora Ellis | 96 | £112,900 | Strong | EMEA | 02 Aug 2026 | 88 | Yes | 07 Jun 2026 | |
| 7 | Atlas Finance | Active | Scale | Ravi Patel | 84 | £96,500 | Watch | NA | 28 Jul 2026 | 62 | Yes | 08 Jun 2026 | |
| 8 | Sable Security | Active | Scale | Elena Rossi | 72 | £88,900 | Strong | EMEA | 29 Sept 2026 | 70 | Yes | 02 Jun 2026 | |
| 9 | Violet Manufacturing | Trial | Scale | Kai Foster | 58 | £65,400 | Watch | APAC | 24 Aug 2026 | 43 | No | 01 Jun 2026 | |
| 10 | Summit Grocery | Active | Growth | Maeve Walsh | 36 | £41,800 | Strong | NA | 16 Sept 2026 | 34 | Yes | 06 Jun 2026 | |
| 11 | Bluejay Travel | Trial | Growth | Tara Lewis | 22 | £24,800 | Watch | LATAM | 19 Jul 2026 | 17 | No | 04 Jun 2026 | |
| 12 | Brightwell Studio | Trial | Growth | Mina Okafor | 18 | £21,400 | Strong | EMEA | 14 Sept 2026 | 16 | No | 09 Jun 2026 | |
| 13 | Mosaic Civic | Active | Starter | George White | 16 | £15,800 | Strong | EMEA | 11 Sept 2026 | 15 | Yes | 10 Jun 2026 | |
| 14 | Kite Education | Trial | Starter | Owen Price | 14 | £11,800 | Strong | EMEA | 31 Aug 2026 | 13 | No | 06 Jun 2026 | |
| 15 | Pioneer Retail | Trial | Starter | Miles Brooks | 9 | £7,800 | Watch | NA | 12 Jul 2026 | 6 | No | 03 Jun 2026 |
Resource API shape
The grid can filter locally today, but the same query-change payload can be sent to a resource endpoint for SQL-backed search, where, order, and pagination later.
{
"search": "",
"filters": {
"status": {
"type": "select",
"values": [
"active",
"trial"
]
},
"renewalDate": {
"type": "date",
"start": "2026-07-01",
"end": "2026-09-30"
}
},
"sort": {
"key": "arr",
"direction": "desc"
}
}Examples
Flush page edges
layout="flush" with gutter-class="px-4 sm:px-6" lines the toolbar, footer and outer cells up with the page gutter.
Articles
Article library
2 of 2 rows
| Designing a useful content library | Draft | 1,200 |
| Keeping content aligned | Published | 860 |
Examples
Virtual rows for in-memory data
10,000 rows held in memory. Search, filters and sorting cover every row, and only the visible rows are mounted.
Usage events
10,000 of 10,000 events
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| # | Filter Event | Filter Account | Filter Service Values | Filter Status Values | Filter Region Values | Filter Requests | Filter Spend | Filter Updated |
| 1 | 1 | Atlas Works 1 | Billing API | Healthy | UK | 2,500 | £150.00 | 01 Jan 2026 |
| 2 | 2 | Northstar Labs 1 | Delayed | EU | 2,597 | £187.00 | 02 Feb 2026 | |
| 3 | 3 | Pioneer Health 1 | Billing API | Queued | US | 2,694 | £224.00 | 03 Mar 2026 |
| 4 | 4 | Evergreen Finance 1 | Healthy | CA | 2,791 | £261.00 | 04 Apr 2026 | |
| 5 | 5 | Lunar Retail 1 | Billing API | Healthy | AU | 2,888 | £298.00 | 05 May 2026 |
| 6 | 6 | Cobalt Systems 1 | Failed | UK | 2,985 | £335.00 | 06 Jun 2026 | |
| 7 | 7 | Atlas Works 2 | Billing API | Healthy | EU | 3,082 | £372.00 | 07 Jan 2026 |
| 8 | 8 | Northstar Labs 2 | Delayed | US | 3,179 | £409.00 | 08 Feb 2026 | |
| 9 | 9 | Pioneer Health 2 | Billing API | Queued | CA | 3,276 | £446.00 | 09 Mar 2026 |
| 10 | 10 | Evergreen Finance 2 | Healthy | AU | 3,373 | £483.00 | 10 Apr 2026 | |
| 11 | 11 | Lunar Retail 2 | Billing API | Healthy | UK | 3,470 | £520.00 | 11 May 2026 |
| 12 | 12 | Cobalt Systems 2 | Failed | EU | 3,567 | £557.00 | 12 Jun 2026 | |
| 13 | 13 | Atlas Works 3 | Billing API | Healthy | US | 3,664 | £594.00 | 13 Jan 2026 |
| 14 | 14 | Northstar Labs 3 | Delayed | CA | 3,761 | £631.00 | 14 Feb 2026 | |
| 15 | 15 | Pioneer Health 3 | Billing API | Queued | AU | 3,858 | £668.00 | 15 Mar 2026 |
| 16 | 16 | Evergreen Finance 3 | Healthy | UK | 3,955 | £705.00 | 16 Apr 2026 | |
| 17 | 17 | Lunar Retail 3 | Billing API | Healthy | EU | 4,052 | £742.00 | 17 May 2026 |
| 18 | 18 | Cobalt Systems 3 | Failed | US | 4,149 | £779.00 | 18 Jun 2026 | |
| 19 | 19 | Atlas Works 4 | Billing API | Healthy | CA | 4,246 | £816.00 | 19 Jan 2026 |
| 20 | 20 | Northstar Labs 4 | Delayed | AU | 4,343 | £853.00 | 20 Feb 2026 | |
| 21 | 21 | Pioneer Health 4 | Billing API | Queued | UK | 4,440 | £890.00 | 21 Mar 2026 |
| 22 | 22 | Evergreen Finance 4 | Healthy | EU | 4,537 | £927.00 | 22 Apr 2026 | |
| 23 | 23 | Lunar Retail 4 | Billing API | Healthy | US | 4,634 | £964.00 | 23 May 2026 |
| 24 | 24 | Cobalt Systems 4 | Failed | CA | 4,731 | £1,001 | 24 Jun 2026 | |
| 25 | 25 | Atlas Works 5 | Billing API | Healthy | AU | 4,828 | £1,038 | 25 Jan 2026 |
| 26 | 26 | Northstar Labs 5 | Delayed | UK | 4,925 | £1,075 | 26 Feb 2026 | |
| 27 | 27 | Pioneer Health 5 | Billing API | Queued | EU | 5,022 | £1,112 | 27 Mar 2026 |
| 28 | 28 | Evergreen Finance 5 | Healthy | US | 5,119 | £1,149 | 28 Apr 2026 | |
| 29 | 29 | Lunar Retail 5 | Billing API | Healthy | CA | 5,216 | £1,186 | 01 May 2026 |
| 30 | 30 | Cobalt Systems 5 | Failed | AU | 5,313 | £1,223 | 02 Jun 2026 | |
| 31 | 31 | Atlas Works 6 | Billing API | Healthy | UK | 5,410 | £1,260 | 03 Jan 2026 |
| 32 | 32 | Northstar Labs 6 | Delayed | EU | 5,507 | £1,297 | 04 Feb 2026 | |
| 33 | 33 | Pioneer Health 6 | Billing API | Queued | US | 5,604 | £1,334 | 05 Mar 2026 |
| 34 | 34 | Evergreen Finance 6 | Healthy | CA | 5,701 | £1,371 | 06 Apr 2026 | |
| 35 | 35 | Lunar Retail 6 | Billing API | Healthy | AU | 5,798 | £1,408 | 07 May 2026 |
| 36 | 36 | Cobalt Systems 6 | Failed | UK | 5,895 | £1,445 | 08 Jun 2026 | |
| 37 | 37 | Atlas Works 7 | Billing API | Healthy | EU | 5,992 | £1,482 | 09 Jan 2026 |
| 38 | 38 | Northstar Labs 7 | Delayed | US | 6,089 | £1,519 | 10 Feb 2026 | |
| 39 | 39 | Pioneer Health 7 | Billing API | Queued | CA | 6,186 | £1,556 | 11 Mar 2026 |
| 40 | 40 | Evergreen Finance 7 | Healthy | AU | 6,283 | £1,593 | 12 Apr 2026 | |
| 41 | 41 | Lunar Retail 7 | Billing API | Healthy | UK | 6,380 | £1,630 | 13 May 2026 |
| 42 | 42 | Cobalt Systems 7 | Failed | EU | 6,477 | £1,667 | 14 Jun 2026 | |
| 43 | 43 | Atlas Works 8 | Billing API | Healthy | US | 6,574 | £1,704 | 15 Jan 2026 |
| 44 | 44 | Northstar Labs 8 | Delayed | CA | 6,671 | £1,741 | 16 Feb 2026 | |
| 45 | 45 | Pioneer Health 8 | Billing API | Queued | AU | 6,768 | £1,778 | 17 Mar 2026 |
| 46 | 46 | Evergreen Finance 8 | Healthy | UK | 6,865 | £1,815 | 18 Apr 2026 | |
| 47 | 47 | Lunar Retail 8 | Billing API | Healthy | EU | 6,962 | £1,852 | 19 May 2026 |
| 48 | 48 | Cobalt Systems 8 | Failed | US | 7,059 | £1,889 | 20 Jun 2026 | |
| 49 | 49 | Atlas Works 9 | Billing API | Healthy | CA | 7,156 | £1,926 | 21 Jan 2026 |
| 50 | 50 | Northstar Labs 9 | Delayed | AU | 7,253 | £1,963 | 22 Feb 2026 | |
| 51 | 51 | Pioneer Health 9 | Billing API | Queued | UK | 7,350 | £2,000 | 23 Mar 2026 |
Examples
Keyword table sort presets
multi-sort turns header clicks into ordered sort rules. Click Volume, then Difficulty, to sort by both.
Keyword opportunity sort
The selected preset sends ordered sort rules, so APIs can translate them to stable ORDER BY clauses.
Keyword research
10 of 10 keywords
| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| # | Filter Keyword | Filter Intent Values | Filter Volume | Filter Difficulty | Filter Opportunity | Filter CPC |
| 1 | vue data grid | Commercial | 5,400 | 28 | 3,942 | $5.40 |
| 2 | javascript spreadsheet component | Comparison | 5,400 | 43 | 3,132 | $7.10 |
| 3 | editable table vue | Commercial | 2,900 | 21 | 2,320 | $4.80 |
| 4 | tailwind data table | Informational | 8,100 | 61 | 3,240 | $3.90 |
| 5 | server side data grid | Commercial | 3,600 | 35 | 2,376 | $8.20 |
| 6 | open source data grid vue | Comparison | 4,400 | 24 | 3,388 | $4.60 |
| 7 | keyword clustering tool | Transactional | 6,600 | 48 | 3,498 | $6.70 |
| 8 | saas dashboard table | Informational | 1,900 | 17 | 1,596 | $3.20 |
| 9 | best vue table library | Comparison | 5,400 | 31 | 3,780 | $5.90 |
| 10 | crm data grid | Commercial | 2,400 | 19 | 1,968 | $9.40 |
{
"search": "",
"sort": {
"key": "",
"direction": "",
"rules": []
}
}Examples
Server pagination and forever scrolling
One resource endpoint serves numbered pages and cursor-based scrolling. The grid sends search, filters and sort; the app adds paging.
Remote customer resource
The grid asks for rows; the mock API applies search, filters, sort, and pagination.
Customers
0 of 0 customers from resource API
| A | B | C | D | E | F | G | ||
|---|---|---|---|---|---|---|---|---|
| # | Filter Customer | Filter Status Values | Filter Owner | Filter Region Values | Filter Seats | Filter ARR | Filter Updated | |
| No rows match the current filters. | ||||||||
Request
{
"resource": "customers",
"query": {
"search": "",
"filters": {},
"sort": {
"key": "updatedAt",
"direction": "desc"
}
},
"pagination": {
"strategy": "page",
"page": 1,
"pageSize": 8
}
}Response
{
"data": [],
"pageInfo": {
"strategy": "page",
"page": 1,
"pageSize": 8,
"pageCount": 1,
"total": 0,
"hasNextPage": false,
"hasPreviousPage": false,
"nextCursor": ""
},
"meta": {
"total": 0,
"returned": 0
}
}Examples
Live SQL query log
New rows stream into a server-backed grid while search, filters and a duration_ms desc sort keep the slowest statements on top.
Buffered
140
P95 duration
936 ms
Slow queries
36
Errors
11
SQL log
80 of 140 queries from resource API
| # | Filter Database Values | Filter Service Values | Filter Verb Values | Filter Table Values | Filter Duration ms | Filter Lock wait | Filter Rows examined | Filter Status Values | Filter Plan Values | Filter Endpoint | Filter SQL statement | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 21:33:56 | Primary writer | Billing sync | INSERT | feature_flags | 1484 ms | 0 ms | 396 | Timeout | Index scan | stripe:insert | insert into feature_flags (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 2 | 21:34:28 | Billing | Billing sync | UPDATE | users | 1452 ms | 0 ms | 918 | Slow | Index scan | stripe:update | update users set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 3 | 21:34:25 | Analytics | Worker | INSERT | accounts | 1440 ms | 143 ms | 1,141 | Error | Index scan | job:accounts:sync | insert into accounts (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 4 | 21:30:02 | Analytics | Webhook | SELECT | feature_flags | 1406 ms | 615 ms | 154,328 | Timeout | Seq scan | /webhooks/feature_flags | select * from feature_flags where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 5 | 21:32:53 | Billing | Worker | SELECT | sessions | 1322 ms | 0 ms | 29,536 | Slow | Seq scan | job:sessions:sync | select * from sessions where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 6 | 21:32:55 | Primary writer | Billing sync | INSERT | audit_events | 1177 ms | 0 ms | 6 | Slow | Index scan | stripe:insert | insert into audit_events (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 7 | 21:34:45 | Analytics | Billing sync | UPDATE | audit_events | 1149 ms | 0 ms | 345 | Slow | Index scan | stripe:update | update audit_events set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 8 | 21:30:47 | Primary writer | Webhook | SELECT | content_items | 936 ms | 0 ms | 635,204 | Slow | Seq scan | /webhooks/content_items | select * from content_items where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 9 | 21:35:40 | Billing | Worker | DELETE | feature_flags | 885 ms | 0 ms | 139 | Slow | Index scan | job:feature_flags:sync | delete from feature_flags where organisation_id = ? and archived_at < now() - interval '90 days' |
| 10 | 21:31:05 | Analytics | App API | SELECT | accounts | 867 ms | 0 ms | 764,861 | Slow | Seq scan | /api/accounts | select * from accounts where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 11 | 21:30:17 | Billing | Worker | SELECT | website_pages | 852 ms | 360 ms | 92,493 | Slow | Nested loop | job:website_pages:sync | select * from website_pages where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 12 | 21:32:21 | Read replica | Reporting | SELECT | billing_events | 772 ms | 0 ms | 592,246 | Slow | Seq scan | /reports/billing_events | select * from billing_events where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 13 | 21:34:58 | Billing | Reporting | SELECT | organisations | 732 ms | 0 ms | 318,401 | Slow | Seq scan | /reports/organisations | select * from organisations where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 14 | 21:35:32 | Analytics | Worker | SELECT | invoices | 691 ms | 0 ms | 509,075 | Slow | Seq scan | job:invoices:sync | select * from invoices where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 15 | 21:28:06 | Billing | App API | DELETE | billing_events | 658 ms | 73 ms | 742 | Slow | Nested loop | /api/billing_events | delete from billing_events where organisation_id = ? and archived_at < now() - interval '90 days' |
| 16 | 21:27:46 | Analytics | Webhook | SELECT | feature_flags | 564 ms | 0 ms | 570,307 | Slow | Seq scan | /webhooks/feature_flags | select * from feature_flags where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 17 | 21:29:49 | Primary writer | App API | SELECT | invoices | 539 ms | 0 ms | 374,807 | Slow | Seq scan | /api/invoices | select * from invoices where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 18 | 21:33:10 | Analytics | App API | INSERT | audit_events | 529 ms | 0 ms | 698 | Slow | Index scan | /api/audit_events | insert into audit_events (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 19 | 21:35:22 | Billing | Webhook | SELECT | accounts | 516 ms | 0 ms | 117,535 | Slow | Seq scan | /webhooks/accounts | select * from accounts where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 20 | 21:31:42 | Analytics | Reporting | UPDATE | content_items | 468 ms | 0 ms | 434 | Slow | Index scan | /reports/content_items | update content_items set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 21 | 21:31:49 | Analytics | Reporting | SELECT | users | 432 ms | 0 ms | 726,742 | Slow | Seq scan | /reports/users | select * from users where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 22 | 21:31:00 | Read replica | Reporting | SELECT | website_pages | 364 ms | 0 ms | 267,545 | Slow | Seq scan | /reports/website_pages | select * from website_pages where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 23 | 21:23:58 | Analytics | Billing sync | SELECT | organisations | 328 ms | 0 ms | 398,727 | Slow | Seq scan | stripe:select | select * from organisations where organisation_id = ? and lower(search_text) like ? order by updated_at desc limit 100 |
| 24 | 21:30:51 | Read replica | Billing sync | INSERT | users | 316 ms | 0 ms | 63 | Slow | Nested loop | stripe:insert | insert into users (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 25 | 21:33:45 | Billing | Billing sync | UPDATE | jobs | 305 ms | 25 ms | 1,102 | Slow | Index scan | stripe:update | update jobs set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 26 | 21:35:33 | Read replica | Webhook | INSERT | invoices | 289 ms | 0 ms | 727 | Slow | Index scan | /webhooks/invoices | insert into invoices (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 27 | 21:32:05 | Analytics | Reporting | UPDATE | feature_flags | 289 ms | 0 ms | 85 | Slow | Nested loop | /reports/feature_flags | update feature_flags set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 28 | 21:32:28 | Billing | Billing sync | INSERT | users | 286 ms | 0 ms | 799 | Error | Index scan | stripe:insert | insert into users (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 29 | 21:33:26 | Primary writer | Worker | UPDATE | feature_flags | 280 ms | 19 ms | 101 | Slow | Index scan | job:feature_flags:sync | update feature_flags set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 30 | 21:31:40 | Primary writer | Worker | INSERT | invoices | 279 ms | 0 ms | 853 | Slow | Nested loop | job:invoices:sync | insert into invoices (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 31 | 21:32:12 | Primary writer | App API | INSERT | feature_flags | 275 ms | 0 ms | 1,066 | Slow | Index scan | /api/feature_flags | insert into feature_flags (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 32 | 21:33:01 | Billing | Webhook | INSERT | organisations | 269 ms | 0 ms | 733 | Slow | Index scan | /webhooks/organisations | insert into organisations (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 33 | 21:29:49 | Read replica | App API | UPDATE | accounts | 268 ms | 0 ms | 641 | Slow | Index scan | /api/accounts | update accounts set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 34 | 21:31:27 | Analytics | Billing sync | INSERT | sessions | 258 ms | 0 ms | 807 | Slow | Index scan | stripe:insert | insert into sessions (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 35 | 21:34:50 | Billing | App API | INSERT | sessions | 254 ms | 0 ms | 551 | Slow | Index scan | /api/sessions | insert into sessions (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 36 | 21:28:19 | Read replica | App API | DELETE | website_pages | 254 ms | 52 ms | 599 | Slow | Index scan | /api/website_pages | delete from website_pages where organisation_id = ? and archived_at < now() - interval '90 days' |
| 37 | 21:31:07 | Analytics | Worker | INSERT | users | 227 ms | 43 ms | 994 | OK | Index scan | job:users:sync | insert into users (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 38 | 21:26:01 | Primary writer | Billing sync | INSERT | website_pages | 201 ms | 0 ms | 677 | OK | Index scan | stripe:insert | insert into website_pages (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 39 | 21:33:57 | Analytics | App API | INSERT | users | 200 ms | 0 ms | 216 | OK | Index scan | /api/users | insert into users (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 40 | 21:35:37 | Primary writer | Webhook | UPDATE | audit_events | 197 ms | 0 ms | 289 | OK | Index scan | /webhooks/audit_events | update audit_events set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 41 | 21:35:08 | Billing | Billing sync | UPDATE | content_items | 190 ms | 0 ms | 271 | OK | Index scan | stripe:update | update content_items set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 42 | 21:33:33 | Analytics | Billing sync | SELECT | billing_events | 189 ms | 0 ms | 5,696 | OK | Index scan | stripe:select | select id, organisation_id, status, updated_at from billing_events where organisation_id = ? and id = ? limit 1 |
| 43 | 21:25:19 | Primary writer | Billing sync | SELECT | jobs | 189 ms | 0 ms | 5,873 | OK | Index scan | stripe:select | select id, organisation_id, status, updated_at from jobs where organisation_id = ? and id = ? limit 1 |
| 44 | 21:30:56 | Billing | Billing sync | SELECT | organisations | 186 ms | 0 ms | 6,275 | OK | Index scan | stripe:select | select id, organisation_id, status, updated_at from organisations where organisation_id = ? and id = ? limit 1 |
| 45 | 21:28:17 | Primary writer | App API | SELECT | jobs | 186 ms | 0 ms | 9,195 | OK | Index scan | /api/jobs | select id, organisation_id, status, updated_at from jobs where organisation_id = ? and id = ? limit 1 |
| 46 | 21:32:54 | Read replica | Webhook | SELECT | billing_events | 184 ms | 0 ms | 4,919 | OK | Index scan | /webhooks/billing_events | select id, organisation_id, status, updated_at from billing_events where organisation_id = ? and id = ? limit 1 |
| 47 | 21:30:43 | Primary writer | Billing sync | SELECT | invoices | 182 ms | 0 ms | 5,707 | OK | Index scan | stripe:select | select id, organisation_id, status, updated_at from invoices where organisation_id = ? and id = ? limit 1 |
| 48 | 21:33:58 | Billing | Reporting | SELECT | users | 181 ms | 0 ms | 3,325 | OK | Index scan | /reports/users | select id, organisation_id, status, updated_at from users where organisation_id = ? and id = ? limit 1 |
| 49 | 21:31:49 | Analytics | Webhook | SELECT | audit_events | 181 ms | 0 ms | 3,685 | OK | Index scan | /webhooks/audit_events | select id, organisation_id, status, updated_at from audit_events where organisation_id = ? and id = ? limit 1 |
| 50 | 21:31:54 | Analytics | App API | SELECT | jobs | 176 ms | 0 ms | 3,588 | OK | Index scan | /api/jobs | select id, organisation_id, status, updated_at from jobs where organisation_id = ? and id = ? limit 1 |
| 51 | 21:34:37 | Primary writer | App API | SELECT | accounts | 175 ms | 0 ms | 3,508 | Error | Index scan | /api/accounts | select id, organisation_id, status, updated_at from accounts where organisation_id = ? and id = ? limit 1 |
| 52 | 21:34:41 | Billing | Worker | SELECT | billing_events | 170 ms | 0 ms | 2,890 | OK | Index scan | job:billing_events:sync | select id, organisation_id, status, updated_at from billing_events where organisation_id = ? and id = ? limit 1 |
| 53 | 21:34:48 | Analytics | App API | SELECT | content_items | 168 ms | 0 ms | 136 | OK | Index scan | /api/content_items | select id, organisation_id, status, updated_at from content_items where organisation_id = ? and id = ? limit 1 |
| 54 | 21:31:10 | Analytics | Worker | INSERT | website_pages | 166 ms | 0 ms | 301 | OK | Index scan | job:website_pages:sync | insert into website_pages (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 55 | 21:25:50 | Read replica | Reporting | SELECT | audit_events | 165 ms | 0 ms | 5,001 | OK | Index scan | /reports/audit_events | select id, organisation_id, status, updated_at from audit_events where organisation_id = ? and id = ? limit 1 |
| 56 | 21:33:38 | Billing | Webhook | INSERT | accounts | 164 ms | 0 ms | 895 | OK | Index scan | /webhooks/accounts | insert into accounts (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 57 | 21:27:47 | Read replica | Reporting | SELECT | audit_events | 163 ms | 0 ms | 1,638 | Error | Index scan | /reports/audit_events | select id, organisation_id, status, updated_at from audit_events where organisation_id = ? and id = ? limit 1 |
| 58 | 21:35:21 | Billing | Worker | UPDATE | invoices | 162 ms | 0 ms | 495 | OK | Index scan | job:invoices:sync | update invoices set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 59 | 21:35:11 | Read replica | Reporting | SELECT | website_pages | 161 ms | 0 ms | 8,303 | OK | Index scan | /reports/website_pages | select id, organisation_id, status, updated_at from website_pages where organisation_id = ? and id = ? limit 1 |
| 60 | 21:34:27 | Primary writer | Worker | SELECT | jobs | 159 ms | 0 ms | 6,274 | OK | Index scan | job:jobs:sync | select id, organisation_id, status, updated_at from jobs where organisation_id = ? and id = ? limit 1 |
| 61 | 21:32:53 | Read replica | Worker | SELECT | billing_events | 159 ms | 0 ms | 2,077 | OK | Index scan | job:billing_events:sync | select id, organisation_id, status, updated_at from billing_events where organisation_id = ? and id = ? limit 1 |
| 62 | 21:32:33 | Analytics | Billing sync | SELECT | organisations | 158 ms | 0 ms | 8,514 | OK | Index scan | stripe:select | select id, organisation_id, status, updated_at from organisations where organisation_id = ? and id = ? limit 1 |
| 63 | 21:34:19 | Billing | Worker | SELECT | website_pages | 153 ms | 0 ms | 4,938 | OK | Index scan | job:website_pages:sync | select id, organisation_id, status, updated_at from website_pages where organisation_id = ? and id = ? limit 1 |
| 64 | 21:30:02 | Billing | Webhook | UPDATE | organisations | 152 ms | 0 ms | 782 | OK | Index scan | /webhooks/organisations | update organisations set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 65 | 21:35:00 | Read replica | Billing sync | SELECT | users | 148 ms | 0 ms | 7,719 | OK | Index scan | stripe:select | select id, organisation_id, status, updated_at from users where organisation_id = ? and id = ? limit 1 |
| 66 | 21:31:38 | Primary writer | Worker | SELECT | organisations | 148 ms | 0 ms | 6,600 | OK | Index scan | job:organisations:sync | select id, organisation_id, status, updated_at from organisations where organisation_id = ? and id = ? limit 1 |
| 67 | 21:35:14 | Read replica | App API | SELECT | feature_flags | 144 ms | 0 ms | 7,576 | OK | Index scan | /api/feature_flags | select id, organisation_id, status, updated_at from feature_flags where organisation_id = ? and id = ? limit 1 |
| 68 | 21:30:24 | Primary writer | Webhook | DELETE | website_pages | 144 ms | 0 ms | 547 | OK | Nested loop | /webhooks/website_pages | delete from website_pages where organisation_id = ? and archived_at < now() - interval '90 days' |
| 69 | 21:30:51 | Billing | Webhook | UPDATE | feature_flags | 141 ms | 0 ms | 505 | OK | Nested loop | /webhooks/feature_flags | update feature_flags set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 70 | 21:35:35 | Billing | Webhook | SELECT | invoices | 140 ms | 0 ms | 5,940 | OK | Index scan | /webhooks/invoices | select id, organisation_id, status, updated_at from invoices where organisation_id = ? and id = ? limit 1 |
| 71 | 21:34:50 | Primary writer | App API | SELECT | accounts | 137 ms | 0 ms | 1,347 | OK | Index scan | /api/accounts | select id, organisation_id, status, updated_at from accounts where organisation_id = ? and id = ? limit 1 |
| 72 | 21:33:04 | Read replica | Webhook | SELECT | audit_events | 135 ms | 0 ms | 8,683 | OK | Index scan | /webhooks/audit_events | select id, organisation_id, status, updated_at from audit_events where organisation_id = ? and id = ? limit 1 |
| 73 | 21:29:45 | Primary writer | Worker | INSERT | invoices | 132 ms | 48 ms | 82 | OK | Nested loop | job:invoices:sync | insert into invoices (organisation_id, payload, created_at) values (?, ?, now()) returning id |
| 74 | 21:31:44 | Analytics | Webhook | SELECT | website_pages | 130 ms | 0 ms | 8,042 | OK | Index scan | /webhooks/website_pages | select id, organisation_id, status, updated_at from website_pages where organisation_id = ? and id = ? limit 1 |
| 75 | 21:35:22 | Billing | Reporting | SELECT | invoices | 125 ms | 0 ms | 6,802 | OK | Index scan | /reports/invoices | select id, organisation_id, status, updated_at from invoices where organisation_id = ? and id = ? limit 1 |
| 76 | 21:31:12 | Read replica | App API | SELECT | invoices | 124 ms | 0 ms | 8,772 | Error | Index scan | /api/invoices | select id, organisation_id, status, updated_at from invoices where organisation_id = ? and id = ? limit 1 |
| 77 | 21:31:09 | Read replica | Reporting | UPDATE | sessions | 124 ms | 0 ms | 1,069 | Error | Index scan | /reports/sessions | update sessions set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 78 | 21:34:45 | Read replica | Billing sync | UPDATE | audit_events | 122 ms | 0 ms | 555 | OK | Nested loop | stripe:update | update audit_events set updated_at = now(), metadata = jsonb_set(metadata, ?, ?) where organisation_id = ? and id = ? |
| 79 | 21:29:02 | Analytics | Billing sync | SELECT | sessions | 122 ms | 0 ms | 2,258 | OK | Index scan | stripe:select | select id, organisation_id, status, updated_at from sessions where organisation_id = ? and id = ? limit 1 |
| 80 | 21:30:19 | Billing | App API | SELECT | organisations | 121 ms | 0 ms | 5,347 | OK | Index scan | /api/organisations | select id, organisation_id, status, updated_at from organisations where organisation_id = ? and id = ? limit 1 |
SQL generated from grid state
select id, started_at, database_name, service_name, operation, table_name, duration_ms, lock_wait_ms, rows_examined, status, plan, endpoint, statement from observability.query_log order by duration_ms DESC limit 80;
Request
{
"resource": "observability.query_log",
"query": {
"search": "",
"filters": {},
"sort": {
"key": "durationMs",
"direction": "desc",
"rules": [
{
"key": "durationMs",
"direction": "desc"
}
]
}
},
"pagination": {
"strategy": "live-window",
"limit": 80,
"buffered": 140
}
}Response window
{
"returned": 80,
"total": 140,
"buffered": 140,
"lastUpdatedAt": "21:35:43"
}Examples
Column components and row actions
column.component renders every cell in a column with your component, here an inline name editor and a row actions menu.
Team members
6 of 6 users
Filter User | Filter Team | Filter Status Values | Filter Last active | |
|---|---|---|---|---|
| Product | Pending | 17 Jun 2026 | ||
| Engineering | Active | 16 Jun 2026 | ||
| Design | Active | 12 Jun 2026 | ||
| Support | Paused | 08 Jun 2026 | ||
| Operations | Pending | 06 Jun 2026 | ||
| Sales | Active | 04 Jun 2026 |
EditNameCell.vue
RowActionsCell.vue
Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
layout | 'card' | 'flush' | string | 'card' | Card keeps the bordered container. Flush removes outer side borders, rounding and shadow; the application owns the surrounding width. |
gutterClass | string | string | 'px-3' | Horizontal padding classes for flush toolbar, footer and outer cell edges, such as px-4 sm:px-6 or px-[var(--page-gutter)]. Ignored in card layout. |
rows | array | Array<unknown> | [] | Rows to render. Each row should be a plain object from an API, store, or database result. |
columns | array | Array< | [] | Column definitions. Each type chooses its own sorting, formatting, and filter controls. |
rowKey | string | function | string | 'id' | Field name or function used to identify each row. |
filters | object | Record<string, unknown> | {} | Controlled filter map keyed by column key. |
sort | object | array | Array<unknown> | {"key":"","direction":""} | Controlled sort state as { key, direction }, or { rules } for multi-column and computed sorting. |
multiSort | boolean | boolean | false | When true, header clicks toggle each sortable column inside an ordered sort.rules array. |
search | string | string | '' | Global search text. |
selectedKeys | array | Array<unknown> | [] | Selected row keys. |
remote | boolean | boolean | false | When true, rows render as supplied and query-change can drive a server/API request. |
loading | boolean | boolean | false | Show a loading row while server data is being fetched. |
virtualRows | boolean | boolean | false | Render large in-memory row sets through the isolated virtual row renderer. |
virtualRowBuffer | number | number | 50 | Rows kept mounted above and below the visible viewport when virtualRows is true. |
virtualRowHeight | number | number | — | Fixed row height in pixels for virtual row calculations. Defaults to the grid density. |
selectable | boolean | boolean | true | Show row selection checkboxes. |
showRowNumbers | boolean | boolean | true | Show spreadsheet-style row numbers. |
showColumnLetters | boolean | boolean | true | Show spreadsheet-style column letters above headers. |
stickyHeader | boolean | boolean | true | Keep header rows visible while the grid scrolls. |
resizableColumns | boolean | boolean | true | Allow users to drag header dividers to resize columns. |
minColumnWidth | number | number | 40 | Minimum column width in pixels while resizing. The default leaves room for the filter icon. |
toolbar | boolean | boolean | true | Show the title, search box, filter chips, and row count toolbar. |
title | string | string | 'Data grid' | Toolbar title. |
resourceLabel | string | string | 'rows' | Human label for the resource being rendered. |
totalRows | number | number | — | Server-side total row count when remote is true. |
density | 'compact' | 'comfortable' | string | 'compact' | Row height density. |
height | string | number | string | '34rem' | Scrollable grid height as a CSS size or pixel number. |
emptyText | string | string | 'No rows match the current filters.' | Message shown when no rows are visible. |
pinnedColumns | object | Record<string, unknown> | — | Controlled pin map keyed by column key, such as { account: 'left', actions: 'right' }. When omitted, each column's pinned value is used. |
groupBy | string | array | Array<unknown> | '' | Column key, or ordered column keys, to group rows by. Group rows show a count and each column aggregate. |
groupsExpanded | boolean | boolean | true | Whether groups start expanded. |
showTotals | boolean | boolean | false | Show a totals row pinned to the bottom with each column aggregate over the filtered rows. |
editable | boolean | boolean | false | Allow inline editing of plain cells. Edits emit cell-update; column editable overrides this per column. |
cellSelection | boolean | boolean | false | Spreadsheet cell focus: arrow-key navigation, drag or Shift range selection, copy, paste, delete and undo. |
columnMenu | boolean | boolean | false | Show a menu on every header with pin and group actions beside the column filter. |
exportable | boolean | boolean | false | Show an Export menu in the toolbar for CSV and Excel (.xlsx) downloads of the filtered, sorted rows. |
exportFileName | string | string | '' | Export file name without an extension. Defaults to the title. |
Auto-generated from Data grid.props and inline _edit hints.
Events
| Name | Payload | Description |
|---|---|---|
| @update:filters | Record<string, DataGridFilter> | Fired when a column filter is applied or cleared. |
| @filter-change | ( | — |
| @update:sort | DataGridSort | Fired when column sorting changes. Complex sort values may include an ordered rules array. |
| @sort-change | ( | — |
| @update:search | string | Fired when the toolbar search changes. |
| @query-change | DataGridQuery | Fired with the normalized search, filters, and sort state. Use this to call a resource API. |
| @viewport-scroll | { scrollTop, scrollHeight, clientHeight, remaining, atEnd } | Fired when the scrollable row viewport scrolls. |
| @viewport-end | { scrollTop, scrollHeight, clientHeight, remaining, atEnd } | Fired when the scrollable row viewport reaches the bottom threshold. Useful for cursor pagination and infinite loading. |
| @cell-update | ({ row, column, rowIndex, rowKey, value, previousValue, source }) | Fired when a cell value changes: an inline edit, paste, delete, undo or redo (source), or a column component emitting update:value. The grid does not mutate rows; write value back to your data. |
| @cell-action | ({ row, column, rowIndex, rowKey, value, formatted, action, payload }) | Re-emitted when a column component emits action. |
| @column-resize | ({ column, key, width, previousWidth, startWidth }) | Fired while a header divider is dragged and a column width changes. |
| @column-resize-end | ({ column, key, width, previousWidth, startWidth }) | Fired when the user finishes resizing a column. |
| @render-window-change | ({ startIndex, endIndex, renderedRows, totalRows, firstVisibleIndex, lastVisibleIndex }) | Fired by the virtual row renderer when the mounted row window changes. |
| @update:selectedKeys | Array<string | number> | Fired when row selection changes. |
| @selection-change | ( | — |
| @row-click | ({ row, index }) | Fired when a data row is clicked. |
| @cells-update | ({ source, changes }) | Fired once per edit, paste, delete, undo or redo with every change, after the individual cell-update events. Use it instead of cell-update to apply a batch in one step. |
| @range-change | DataGridRange | null | Fired when the selected cell range changes. Indexes are visible positions. |
| @update:pinnedColumns | Record<string, 'left' | 'right'> | Fired when a column is pinned or unpinned from the column menu. |
| @update:groupBy | string | Array<string> | Fired when grouping changes from the column menu or toolbar. |
| @export | ({ format, rows, columns, fileName }) | Fired after a CSV or Excel export is built. |
Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.
Slots
| Name | Scope | Description |
|---|---|---|
| #cell | { row, column, value, formatted, rowIndex, rowKey } | Custom cell renderer. |
| #toolbar-actions | { query, clearFilters, exportCsv, exportExcel } | Extra controls rendered next to search and Clear filters. |
| #empty | — | Custom empty state. |
Methods
Call these on a template ref.
| Name | Signature | Description |
|---|---|---|
| exportCsv | (options?: { onlySelected?: boolean; fileName?: string; download?: boolean }) => string | Downloads the filtered, sorted rows as CSV and returns the text. Pass download: false to only get the text. |
| exportExcel | (options?: { onlySelected?: boolean; fileName?: string; download?: boolean }) => Blob | Downloads the filtered, sorted rows as an Excel workbook with typed cells and returns the file. |
| getRange | () => DataGridRange | null | Returns the selected cell range, or null. |
| selectRange | (range: { startRow: number; endRow?: number; startColumn?: number | string; endColumn?: number | string }) => void | Selects a cell range by visible row position and column index or key. |
| clearRange | () => void | Clears the active cell and range. |
| undo | () => boolean | Undoes the last edit, paste or delete. Returns false when there is nothing to undo. |
| redo | () => boolean | Redoes the last undone change. Returns false when there is nothing to redo. |
| expandAllGroups | () => void | Opens every group. |
| collapseAllGroups | () => void | Closes every group. |
Keyboard
- Enter / Space on a headerToggle sort when the column is sortable.
- Enter / Space on a filter buttonOpen the typed filter popover.
- EscClose an open filter popover, cancel an edit, or collapse a range to the active cell.
- Arrow keys / TabMove the active cell when cellSelection is on. Shift extends the range; Cmd or Ctrl jumps to the edge.
- Enter / F2 / typingEdit the active cell when it is editable. Enter commits and moves down; Tab commits and moves right.
- Cmd/Ctrl + C, X, VCopy, cut and paste the range as tab-separated text, compatible with Excel and Google Sheets.
- Delete / BackspaceClear editable cells in the range.
- Cmd/Ctrl + ASelect every visible cell.
- Cmd/Ctrl + Z, Shift + Cmd/Ctrl + ZUndo and redo edits made in the grid.