Component
Data grid
<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.
Demo
Database-style account table
Column filters use typed controls: date columns open a date range, number columns use min/max, select columns use checklist filters, and applied filters are visible in both the header and 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"
}
}<script setup>
import { computed, ref } from 'vue';
import DomDataGrid from '../DomDataGrid.vue';
const filters = ref({
status: { type: 'select', values: ['active', 'trial'] },
renewalDate: { type: 'date', start: '2026-07-01', end: '2026-09-30' },
});
const sort = ref({ key: 'arr', direction: 'desc' });
const search = ref('');
const selectedKeys = ref(['acc-1005', 'acc-1014']);
const lastQuery = ref(null);
const columns = [
{ key: 'account', label: 'Account', type: 'text', width: '15rem' },
{
key: 'status',
label: 'Status',
type: 'select',
width: '8.5rem',
options: [
{ label: 'Active', value: 'active', tone: 'green' },
{ label: 'Trial', value: 'trial', tone: 'blue' },
{ label: 'Paused', value: 'paused', tone: 'amber' },
{ label: 'Risk', value: 'risk', tone: 'red' },
],
},
{
key: 'plan',
label: 'Plan',
type: 'select',
width: '8.5rem',
options: [
{ label: 'Starter', value: 'starter', tone: 'neutral' },
{ label: 'Growth', value: 'growth', tone: 'blue' },
{ label: 'Scale', value: 'scale', tone: 'violet' },
{ label: 'Enterprise', value: 'enterprise', tone: 'green' },
],
},
{ key: 'owner', label: 'Owner', type: 'text', width: '11rem' },
{ key: 'seats', label: 'Seats', type: 'number', width: '6.5rem' },
{ key: 'arr', label: 'ARR', type: 'currency', currency: 'GBP', width: '8.5rem' },
{
key: 'health',
label: 'Health',
type: 'select',
width: '8.25rem',
options: [
{ label: 'Strong', value: 'strong', tone: 'green' },
{ label: 'Watch', value: 'watch', tone: 'amber' },
{ label: 'Weak', value: 'weak', tone: 'red' },
],
},
{ key: 'region', label: 'Region', type: 'select', width: '8rem' },
{ key: 'renewalDate', label: 'Renewal', type: 'date', width: '9.5rem' },
{ key: 'activeUsers', label: 'Active users', type: 'number', width: '8rem' },
{ key: 'verified', label: 'Verified', type: 'boolean', width: '7rem' },
{ key: 'updatedAt', label: 'Updated', type: 'date', width: '9.5rem' },
];
const rows = [
{ id: 'acc-1001', account: 'Northstar Analytics', status: 'active', plan: 'enterprise', owner: 'Amelia Hart', seats: 142, arr: 184000, health: 'strong', region: 'EMEA', renewalDate: '2026-08-19', activeUsers: 118, verified: true, updatedAt: '2026-06-10' },
{ id: 'acc-1002', account: 'Atlas Finance', status: 'active', plan: 'scale', owner: 'Ravi Patel', seats: 84, arr: 96500, health: 'watch', region: 'NA', renewalDate: '2026-07-28', activeUsers: 62, verified: true, updatedAt: '2026-06-08' },
{ id: 'acc-1003', account: 'Brightwell Studio', status: 'trial', plan: 'growth', owner: 'Mina Okafor', seats: 18, arr: 21400, health: 'strong', region: 'EMEA', renewalDate: '2026-09-14', activeUsers: 16, verified: false, updatedAt: '2026-06-09' },
{ id: 'acc-1004', account: 'Cinder Labs', status: 'risk', plan: 'scale', owner: 'Theo Grant', seats: 67, arr: 81200, health: 'weak', region: 'APAC', renewalDate: '2026-10-03', activeUsers: 29, verified: true, updatedAt: '2026-06-05' },
{ id: 'acc-1005', account: 'Orchid Health', status: 'active', plan: 'enterprise', owner: 'Laura Chen', seats: 232, arr: 268000, health: 'strong', region: 'NA', renewalDate: '2026-09-22', activeUsers: 211, verified: true, updatedAt: '2026-06-11' },
{ id: 'acc-1006', account: 'Juniper Works', status: 'paused', plan: 'growth', owner: 'Sam Rivera', seats: 31, arr: 38200, health: 'watch', region: 'LATAM', renewalDate: '2026-11-17', activeUsers: 12, verified: false, updatedAt: '2026-05-30' },
{ id: 'acc-1007', account: 'Signal Harbor', status: 'active', plan: 'scale', owner: 'Nora Ellis', seats: 96, arr: 112900, health: 'strong', region: 'EMEA', renewalDate: '2026-08-02', activeUsers: 88, verified: true, updatedAt: '2026-06-07' },
{ id: 'acc-1008', account: 'Pioneer Retail', status: 'trial', plan: 'starter', owner: 'Miles Brooks', seats: 9, arr: 7800, health: 'watch', region: 'NA', renewalDate: '2026-07-12', activeUsers: 6, verified: false, updatedAt: '2026-06-03' },
{ id: 'acc-1009', account: 'Forge Robotics', status: 'active', plan: 'enterprise', owner: 'Priya Singh', seats: 188, arr: 224500, health: 'strong', region: 'APAC', renewalDate: '2026-09-04', activeUsers: 169, verified: true, updatedAt: '2026-06-10' },
{ id: 'acc-1010', account: 'Lakehouse Legal', status: 'risk', plan: 'growth', owner: 'Jon Bell', seats: 27, arr: 29600, health: 'weak', region: 'EMEA', renewalDate: '2026-06-26', activeUsers: 11, verified: true, updatedAt: '2026-05-28' },
{ id: 'acc-1011', account: 'Verde Energy', status: 'active', plan: 'scale', owner: 'Iris Morgan', seats: 121, arr: 136800, health: 'watch', region: 'NA', renewalDate: '2026-10-21', activeUsers: 94, verified: true, updatedAt: '2026-06-01' },
{ id: 'acc-1012', account: 'Kite Education', status: 'trial', plan: 'starter', owner: 'Owen Price', seats: 14, arr: 11800, health: 'strong', region: 'EMEA', renewalDate: '2026-08-31', activeUsers: 13, verified: false, updatedAt: '2026-06-06' },
{ id: 'acc-1013', account: 'Evergreen Supply', status: 'paused', plan: 'growth', owner: 'Fatima Noor', seats: 44, arr: 48700, health: 'watch', region: 'APAC', renewalDate: '2026-12-05', activeUsers: 18, verified: true, updatedAt: '2026-05-24' },
{ id: 'acc-1014', account: 'Mercury Media', status: 'active', plan: 'enterprise', owner: 'Caleb Stone', seats: 176, arr: 201300, health: 'strong', region: 'NA', renewalDate: '2026-07-09', activeUsers: 151, verified: true, updatedAt: '2026-06-09' },
{ id: 'acc-1015', account: 'Sable Security', status: 'active', plan: 'scale', owner: 'Elena Rossi', seats: 72, arr: 88900, health: 'strong', region: 'EMEA', renewalDate: '2026-09-29', activeUsers: 70, verified: true, updatedAt: '2026-06-02' },
{ id: 'acc-1016', account: 'Copper Bank', status: 'risk', plan: 'enterprise', owner: 'Daniel Kim', seats: 205, arr: 241900, health: 'weak', region: 'NA', renewalDate: '2026-08-07', activeUsers: 132, verified: true, updatedAt: '2026-05-31' },
{ id: 'acc-1017', account: 'Bluejay Travel', status: 'trial', plan: 'growth', owner: 'Tara Lewis', seats: 22, arr: 24800, health: 'watch', region: 'LATAM', renewalDate: '2026-07-19', activeUsers: 17, verified: false, updatedAt: '2026-06-04' },
{ id: 'acc-1018', account: 'Mosaic Civic', status: 'active', plan: 'starter', owner: 'George White', seats: 16, arr: 15800, health: 'strong', region: 'EMEA', renewalDate: '2026-09-11', activeUsers: 15, verified: true, updatedAt: '2026-06-10' },
{ id: 'acc-1019', account: 'Harbor FreightOps', status: 'active', plan: 'scale', owner: 'Nadia Cole', seats: 109, arr: 121400, health: 'watch', region: 'APAC', renewalDate: '2026-10-13', activeUsers: 83, verified: true, updatedAt: '2026-06-07' },
{ id: 'acc-1020', account: 'Quartz Design Co', status: 'paused', plan: 'starter', owner: 'Hugo Martin', seats: 11, arr: 9200, health: 'weak', region: 'EMEA', renewalDate: '2026-11-02', activeUsers: 4, verified: false, updatedAt: '2026-05-21' },
{ id: 'acc-1021', account: 'Anchor Cloud', status: 'active', plan: 'enterprise', owner: 'Sofia Jensen', seats: 264, arr: 310000, health: 'strong', region: 'NA', renewalDate: '2026-07-31', activeUsers: 240, verified: true, updatedAt: '2026-06-11' },
{ id: 'acc-1022', account: 'Violet Manufacturing', status: 'trial', plan: 'scale', owner: 'Kai Foster', seats: 58, arr: 65400, health: 'watch', region: 'APAC', renewalDate: '2026-08-24', activeUsers: 43, verified: false, updatedAt: '2026-06-01' },
{ id: 'acc-1023', account: 'Summit Grocery', status: 'active', plan: 'growth', owner: 'Maeve Walsh', seats: 36, arr: 41800, health: 'strong', region: 'NA', renewalDate: '2026-09-16', activeUsers: 34, verified: true, updatedAt: '2026-06-06' },
{ id: 'acc-1024', account: 'Redwood Insurance', status: 'risk', plan: 'scale', owner: 'Imani Reed', seats: 91, arr: 104200, health: 'weak', region: 'EMEA', renewalDate: '2026-07-07', activeUsers: 49, verified: true, updatedAt: '2026-05-29' },
];
const queryPreview = computed(() => JSON.stringify(lastQuery.value || {
search: search.value,
filters: filters.value,
sort: sort.value,
}, null, 2));
</script>
<template>
<div class="w-full min-w-0">
<DomDataGrid
v-model:filters="filters"
v-model:sort="sort"
v-model:search="search"
v-model:selected-keys="selectedKeys"
:rows="rows"
:columns="columns"
title="Customer accounts"
resource-label="accounts"
height="35rem"
@query-change="lastQuery = $event"
>
<template #toolbar-actions>
<div class="hidden items-center gap-2 rounded-md border border-border bg-canvas px-2.5 py-1.5 text-xs font-medium text-muted-fg lg:flex">
<span class="size-2 rounded-full bg-emerald-500"></span>
SQL-ready query state
</div>
</template>
</DomDataGrid>
<div class="mt-4 grid gap-3 rounded-lg border border-border bg-canvas p-4 text-sm lg:grid-cols-[minmax(0,1fr)_20rem]">
<div>
<p class="font-semibold text-canvas-fg">Resource API shape</p>
<p class="mt-1 leading-6 text-muted-fg">
The grid can filter locally today, but the same <code class="text-canvas-fg">query-change</code> payload can be sent to a resource endpoint for SQL-backed search, where, order, and pagination later.
</p>
</div>
<pre class="max-h-48 overflow-auto rounded-md border border-border bg-secondary/40 p-3 text-xs leading-5 text-canvas-fg">{{ queryPreview }}</pre>
</div>
</div>
</template>
Large memory
Virtual rows for in-memory data
Use virtualRows when a large local array is already in memory. The grid keeps the full 10,000-row data set available for client-side search, filters, and sort, while the isolated virtual row renderer mounts only the visible rows plus the configured buffer.
Usage events
10000 of 10000 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 |
<script setup>
import { computed, ref } from 'vue';
import { DomDataGrid } from '../../../lib/vue';
const rows = createRows(10000);
const renderWindow = ref({
startIndex: 0,
endIndex: 0,
renderedRows: 0,
totalRows: rows.length,
});
const columns = [
{ key: 'id', label: 'Event', type: 'number', width: '7rem' },
{ key: 'account', label: 'Account', type: 'text', width: '15rem' },
{
key: 'service',
label: 'Service',
type: 'select',
width: '11rem',
options: ['Billing API', 'Checkout', 'Identity', 'Mail', 'Storage', 'Analytics'],
},
{
key: 'status',
label: 'Status',
type: 'select',
width: '9rem',
options: [
{ label: 'Healthy', value: 'healthy', tone: 'green' },
{ label: 'Delayed', value: 'delayed', tone: 'amber' },
{ label: 'Failed', value: 'failed', tone: 'red' },
{ label: 'Queued', value: 'queued', tone: 'blue' },
],
},
{
key: 'region',
label: 'Region',
type: 'select',
width: '8rem',
options: ['UK', 'EU', 'US', 'CA', 'AU'],
},
{ key: 'requests', label: 'Requests', type: 'number', width: '9rem' },
{ key: 'spend', label: 'Spend', type: 'currency', width: '9rem' },
{ key: 'updatedAt', label: 'Updated', type: 'date', width: '9.5rem' },
];
const renderedRange = computed(() => {
const window = renderWindow.value;
if (!window.totalRows) return '0';
return `${(window.startIndex + 1).toLocaleString()}-${window.endIndex.toLocaleString()}`;
});
function createRows(count) {
const accounts = ['Atlas Works', 'Northstar Labs', 'Pioneer Health', 'Evergreen Finance', 'Lunar Retail', 'Cobalt Systems'];
const services = ['Billing API', 'Checkout', 'Identity', 'Mail', 'Storage', 'Analytics'];
const statuses = ['healthy', 'delayed', 'queued', 'healthy', 'healthy', 'failed'];
const regions = ['UK', 'EU', 'US', 'CA', 'AU'];
return Array.from({ length: count }, (_, index) => {
const accountIndex = index % accounts.length;
const serviceIndex = (index * 3) % services.length;
const day = (index % 28) + 1;
const month = ((index % 6) + 1).toString().padStart(2, '0');
return {
id: index + 1,
account: `${accounts[accountIndex]} ${Math.floor(index / accounts.length) + 1}`,
service: services[serviceIndex],
status: statuses[index % statuses.length],
region: regions[index % regions.length],
requests: 2500 + ((index * 97) % 92000),
spend: 150 + ((index * 37) % 18000),
updatedAt: `2026-${month}-${day.toString().padStart(2, '0')}`,
};
});
}
function updateRenderWindow(value) {
renderWindow.value = value;
}
</script>
<template>
<DomDataGrid
virtual-rows
:virtual-row-buffer="50"
:rows="rows"
:columns="columns"
:selectable="false"
title="Usage events"
resource-label="events"
height="34rem"
@render-window-change="updateRenderWindow"
>
<template #toolbar-actions>
<div class="hidden flex-wrap items-center gap-2 text-xs font-medium lg:flex">
<span class="rounded-md border border-border bg-canvas px-2.5 py-1.5 text-muted-fg">
{{ rows.length.toLocaleString() }} in memory
</span>
<span data-virtual-rendered class="rounded-md border border-border bg-canvas px-2.5 py-1.5 text-muted-fg">
{{ renderWindow.renderedRows }} mounted
</span>
<span data-virtual-range class="rounded-md border border-border bg-canvas px-2.5 py-1.5 text-muted-fg">
{{ renderedRange }}
</span>
</div>
</template>
</DomDataGrid>
</template>
Advanced sorting
Keyword table sort presets
Enable multiSort when header clicks should toggle ordered sort rules per column. Click Volume to add it, click Difficulty to add a second rule, and click a sorted header through asc, desc, and off.
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": []
}
}<script setup>
import { computed, ref, watch } from 'vue';
import { DomDataGrid, DomNativeSelect } from '../../../lib/vue';
const selectedSort = ref('manual');
const sort = ref(null);
const search = ref('');
const sortPresets = [
{
label: 'Manual header clicks',
value: 'manual',
sort: null,
},
{
label: 'Highest volume, easiest difficulty',
value: 'volume-easy',
sort: {
key: 'searchVolume',
direction: 'desc',
rules: [
{ key: 'searchVolume', direction: 'desc' },
{ key: 'difficulty', direction: 'asc' },
],
},
},
{
label: 'Easiest difficulty, then volume',
value: 'easy-volume',
sort: {
key: 'difficulty',
direction: 'asc',
rules: [
{ key: 'difficulty', direction: 'asc' },
{ key: 'searchVolume', direction: 'desc' },
],
},
},
{
label: 'Best opportunity score',
value: 'opportunity',
sort: {
key: 'opportunity',
direction: 'desc',
rules: [
{ key: 'opportunity', direction: 'desc' },
{ key: 'searchVolume', direction: 'desc' },
{ key: 'difficulty', direction: 'asc' },
],
},
},
];
const sortOptions = sortPresets.map((preset) => ({
label: preset.label,
value: preset.value,
}));
const columns = [
{ key: 'keyword', label: 'Keyword', type: 'text', width: '17rem' },
{
key: 'intent',
label: 'Intent',
type: 'select',
width: '9rem',
options: [
{ label: 'Commercial', value: 'commercial', tone: 'green' },
{ label: 'Comparison', value: 'comparison', tone: 'blue' },
{ label: 'Informational', value: 'informational', tone: 'neutral' },
{ label: 'Transactional', value: 'transactional', tone: 'violet' },
],
},
{ key: 'searchVolume', label: 'Volume', type: 'number', width: '8rem' },
{ key: 'difficulty', label: 'Difficulty', type: 'number', width: '8rem' },
{ key: 'opportunity', label: 'Opportunity', type: 'number', width: '9rem' },
{ key: 'cpc', label: 'CPC', type: 'currency', currency: 'USD', locale: 'en-US', width: '7.5rem' },
];
const rows = [
{ id: 'kw-001', keyword: 'vue data grid', intent: 'commercial', searchVolume: 5400, difficulty: 28, cpc: 5.4 },
{ id: 'kw-002', keyword: 'javascript spreadsheet component', intent: 'comparison', searchVolume: 5400, difficulty: 43, cpc: 7.1 },
{ id: 'kw-003', keyword: 'editable table vue', intent: 'commercial', searchVolume: 2900, difficulty: 21, cpc: 4.8 },
{ id: 'kw-004', keyword: 'tailwind data table', intent: 'informational', searchVolume: 8100, difficulty: 61, cpc: 3.9 },
{ id: 'kw-005', keyword: 'server side data grid', intent: 'commercial', searchVolume: 3600, difficulty: 35, cpc: 8.2 },
{ id: 'kw-006', keyword: 'open source data grid vue', intent: 'comparison', searchVolume: 4400, difficulty: 24, cpc: 4.6 },
{ id: 'kw-007', keyword: 'keyword clustering tool', intent: 'transactional', searchVolume: 6600, difficulty: 48, cpc: 6.7 },
{ id: 'kw-008', keyword: 'saas dashboard table', intent: 'informational', searchVolume: 1900, difficulty: 17, cpc: 3.2 },
{ id: 'kw-009', keyword: 'best vue table library', intent: 'comparison', searchVolume: 5400, difficulty: 31, cpc: 5.9 },
{ id: 'kw-010', keyword: 'crm data grid', intent: 'commercial', searchVolume: 2400, difficulty: 19, cpc: 9.4 },
].map((row) => ({
...row,
opportunity: Math.round(row.searchVolume * ((101 - row.difficulty) / 100)),
}));
const queryPreview = computed(() => JSON.stringify({
search: search.value,
sort: sort.value,
}, null, 2));
watch(selectedSort, (value) => {
const preset = sortPresets.find((item) => item.value === value) || sortPresets[0];
sort.value = preset.sort ? cloneSort(preset.sort) : { key: '', direction: '', rules: [] };
}, { immediate: true });
function cloneSort(value) {
return {
...value,
rules: value.rules.map((rule) => ({ ...rule })),
};
}
</script>
<template>
<div class="w-full min-w-0 space-y-4">
<div class="flex flex-col gap-3 rounded-lg border border-border bg-canvas p-4 lg:flex-row lg:items-end lg:justify-between">
<div class="min-w-0">
<p class="text-sm font-semibold text-canvas-fg">Keyword opportunity sort</p>
<p class="mt-1 max-w-2xl text-sm leading-6 text-muted-fg">
The selected preset sends ordered sort rules, so APIs can translate them to stable ORDER BY clauses.
</p>
</div>
<DomNativeSelect
v-model="selectedSort"
class="w-full lg:w-80"
label="Sort preset"
:options="sortOptions"
placeholder=""
/>
</div>
<DomDataGrid
v-model:sort="sort"
v-model:search="search"
:rows="rows"
:columns="columns"
:selectable="false"
title="Keyword research"
resource-label="keywords"
height="27rem"
multi-sort
>
<template #toolbar-actions>
<span class="hidden rounded-md border border-border bg-canvas px-2.5 py-1.5 text-xs font-medium text-muted-fg lg:inline-flex">
Multi-rule sort active
</span>
</template>
</DomDataGrid>
<pre class="max-h-64 overflow-auto rounded-lg border border-border bg-secondary/40 p-4 text-xs leading-5 text-canvas-fg">{{ queryPreview }}</pre>
</div>
</template>
Remote data
Server pagination and forever scrolling
The same resource endpoint handles page-number navigation and cursor-based forever scrolling. The grid sends search, filters, and sort; pagination adds either page/pageSize or cursor/limit.
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
}
}<script setup>
import { computed, onMounted, ref } from 'vue';
import { DomButton, DomDataGrid, DomPagination } from '@getdom/studio/vue';
const pageSize = 8;
const mode = ref('page');
const page = ref(1);
const rows = ref([]);
const loading = ref(false);
const selectedKeys = ref([]);
const search = ref('');
const filters = ref({});
const sort = ref({ key: 'updatedAt', direction: 'desc' });
const currentQuery = ref(queryFromState());
const pageInfo = ref(emptyPageInfo('page'));
const lastRequest = ref(null);
const lastResponse = ref(null);
const columns = [
{ key: 'name', label: 'Customer', type: 'text', width: '15rem' },
{
key: 'status',
label: 'Status',
type: 'select',
width: '8.25rem',
options: [
{ label: 'Active', value: 'active', tone: 'green' },
{ label: 'Trial', value: 'trial', tone: 'blue' },
{ label: 'Paused', value: 'paused', tone: 'amber' },
{ label: 'Risk', value: 'risk', tone: 'red' },
],
},
{ key: 'owner', label: 'Owner', type: 'text', width: '11rem' },
{ key: 'region', label: 'Region', type: 'select', width: '7.5rem' },
{ key: 'seats', label: 'Seats', type: 'number', width: '6.5rem' },
{ key: 'arr', label: 'ARR', type: 'currency', currency: 'GBP', width: '8.5rem' },
{ key: 'updatedAt', label: 'Updated', type: 'date', width: '9rem' },
];
const api = createCustomerResourceApi();
const requestPreview = computed(() => JSON.stringify(lastRequest.value || buildRequest(), null, 2));
const responsePreview = computed(() => JSON.stringify(lastResponse.value || {
data: [],
pageInfo: pageInfo.value,
meta: { total: 0, returned: 0 },
}, null, 2));
const gridHeight = computed(() => mode.value === 'cursor' ? '18rem' : '30rem');
onMounted(() => {
loadResource();
});
function queryFromState() {
return {
search: search.value.trim(),
filters: filters.value,
sort: sort.value?.direction ? sort.value : null,
};
}
function buildRequest(next = {}) {
const strategy = next.mode || mode.value;
const query = next.query || currentQuery.value;
return {
resource: 'customers',
query,
pagination: strategy === 'cursor'
? {
strategy: 'cursor',
cursor: next.cursor || '',
limit: pageSize,
}
: {
strategy: 'page',
page: next.page || page.value,
pageSize,
},
};
}
async function loadResource(options = {}) {
const append = Boolean(options.append);
const nextPage = options.page || page.value;
const nextMode = options.mode || mode.value;
const request = buildRequest({
mode: nextMode,
page: nextPage,
cursor: append ? pageInfo.value.nextCursor : '',
});
loading.value = true;
lastRequest.value = request;
const response = await api.list(request);
if (nextMode === 'cursor' && append) {
rows.value = [...rows.value, ...response.data];
} else {
rows.value = response.data;
}
page.value = response.pageInfo.page || nextPage;
pageInfo.value = response.pageInfo;
lastResponse.value = response;
loading.value = false;
}
function loadNextCursorPage() {
if (mode.value !== 'cursor' || loading.value || !pageInfo.value.hasNextPage) return;
loadResource({ append: true });
}
function handleQueryChange(query) {
currentQuery.value = query;
page.value = 1;
pageInfo.value = emptyPageInfo(mode.value);
loadResource({ page: 1 });
}
function setMode(nextMode) {
if (mode.value === nextMode) return;
mode.value = nextMode;
page.value = 1;
rows.value = [];
pageInfo.value = emptyPageInfo(nextMode);
loadResource({ mode: nextMode, page: 1 });
}
function emptyPageInfo(strategy) {
return {
strategy,
page: 1,
pageSize,
pageCount: 1,
total: 0,
hasNextPage: false,
hasPreviousPage: false,
nextCursor: '',
};
}
function createCustomerResourceApi() {
const records = createCustomers();
return {
async list(request) {
await wait(420);
const queried = applyResourceQuery(records, request.query);
const total = queried.length;
const pagination = request.pagination || {};
if (pagination.strategy === 'cursor') {
const limit = positiveInteger(pagination.limit, pageSize);
const start = cursorToIndex(pagination.cursor);
const data = queried.slice(start, start + limit);
const nextIndex = start + data.length;
const hasNextPage = nextIndex < total;
return {
data,
pageInfo: {
strategy: 'cursor',
limit,
total,
returned: data.length,
hasNextPage,
hasPreviousPage: start > 0,
nextCursor: hasNextPage ? indexToCursor(nextIndex) : '',
},
meta: responseMeta(request, total, data.length),
};
}
const pageSizeValue = positiveInteger(pagination.pageSize, pageSize);
const pageCount = Math.max(1, Math.ceil(total / pageSizeValue));
const requestedPage = positiveInteger(pagination.page, 1);
const safePage = Math.min(pageCount, requestedPage);
const start = (safePage - 1) * pageSizeValue;
const data = queried.slice(start, start + pageSizeValue);
return {
data,
pageInfo: {
strategy: 'page',
page: safePage,
pageSize: pageSizeValue,
pageCount,
total,
returned: data.length,
hasNextPage: safePage < pageCount,
hasPreviousPage: safePage > 1,
nextCursor: safePage < pageCount ? indexToCursor(start + data.length) : '',
},
meta: responseMeta(request, total, data.length),
};
},
};
}
function applyResourceQuery(records, query) {
const normalized = query || {};
const activeFilters = normalized.filters || {};
const needle = String(normalized.search || '').trim().toLowerCase();
const filtered = records.filter((record) => {
if (needle && ![record.name, record.owner, record.region, record.status].some((value) => String(value).toLowerCase().includes(needle))) {
return false;
}
return Object.entries(activeFilters).every(([key, filter]) => recordMatchesFilter(record, key, filter));
});
const sortRules = normalizedSortRules(normalized.sort);
if (!sortRules.length) return filtered;
return [...filtered].sort((a, b) => {
for (const rule of sortRules) {
const direction = rule.direction === 'desc' ? -1 : 1;
const left = sortableValue(a[rule.field || rule.key]);
const right = sortableValue(b[rule.field || rule.key]);
if (left > right) return direction;
if (left < right) return direction * -1;
}
return 0;
});
}
function normalizedSortRules(sort) {
if (!sort) return [];
if (Array.isArray(sort.rules)) {
return sort.rules.filter((rule) => rule?.key && (rule.direction === 'asc' || rule.direction === 'desc'));
}
if (sort.key && (sort.direction === 'asc' || sort.direction === 'desc')) return [sort];
return [];
}
function recordMatchesFilter(record, key, filter) {
if (!filter) return true;
const value = record[key];
if (filter.type === 'select') {
const values = Array.isArray(filter.values) ? filter.values.map(String) : [];
return !values.length || values.includes(String(value));
}
if (filter.type === 'number' || filter.type === 'currency') {
const number = Number(value);
if (filter.min !== '' && filter.min !== null && filter.min !== undefined && number < Number(filter.min)) return false;
if (filter.max !== '' && filter.max !== null && filter.max !== undefined && number > Number(filter.max)) return false;
return true;
}
if (filter.type === 'date') {
const date = String(value).slice(0, 10);
if (filter.start && date < filter.start) return false;
if (filter.end && date > filter.end) return false;
return true;
}
const haystack = String(value ?? '').toLowerCase();
const text = String(filter.value ?? '').trim().toLowerCase();
if (!text) return true;
if (filter.operator === 'equals') return haystack === text;
if (filter.operator === 'starts') return haystack.startsWith(text);
return haystack.includes(text);
}
function sortableValue(value) {
if (typeof value === 'number') return value;
return String(value ?? '').toLowerCase();
}
function responseMeta(request, total, returned) {
return {
total,
returned,
requestedAt: new Date().toISOString(),
resource: request.resource,
query: request.query,
};
}
function positiveInteger(value, fallback) {
const number = Math.floor(Number(value));
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function indexToCursor(index) {
return `customer:${index}`;
}
function cursorToIndex(cursor) {
const match = String(cursor || '').match(/^customer:(\d+)$/);
return match ? Number(match[1]) : 0;
}
function wait(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function createCustomers() {
const names = [
'Northstar Analytics',
'Atlas Finance',
'Brightwell Studio',
'Cinder Labs',
'Orchid Health',
'Juniper Works',
'Signal Harbor',
'Pioneer Retail',
'Forge Robotics',
'Lakehouse Legal',
'Verde Energy',
'Kite Education',
];
const owners = ['Amelia Hart', 'Ravi Patel', 'Mina Okafor', 'Theo Grant', 'Laura Chen', 'Sam Rivera'];
const regions = ['EMEA', 'NA', 'APAC', 'LATAM'];
const statuses = ['active', 'trial', 'paused', 'risk'];
return Array.from({ length: 48 }, (_, index) => {
const status = statuses[index % statuses.length];
return {
id: `acc-${String(index + 1001).padStart(4, '0')}`,
name: names[index % names.length],
status,
owner: owners[index % owners.length],
region: regions[index % regions.length],
seats: 8 + (index * 17) % 260,
arr: 7800 + (index * 13750) % 318000,
updatedAt: `2026-06-${String(index % 18 + 1).padStart(2, '0')}`,
};
});
}
</script>
<template>
<div class="w-full min-w-0 space-y-4">
<div class="flex flex-col gap-3 rounded-lg border border-border bg-canvas p-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-semibold text-canvas-fg">Remote customer resource</p>
<p class="mt-1 text-sm leading-6 text-muted-fg">
The grid asks for rows; the mock API applies search, filters, sort, and pagination.
</p>
</div>
<div class="inline-grid grid-cols-2 rounded-full border border-border bg-secondary/60 p-1 text-sm font-medium">
<button
type="button"
class="rounded-full px-3 py-1.5 transition"
:class="mode === 'page' ? 'bg-canvas text-canvas-fg shadow-sm' : 'text-muted-fg hover:text-canvas-fg'"
@click="setMode('page')"
>
Pages
</button>
<button
type="button"
class="rounded-full px-3 py-1.5 transition"
:class="mode === 'cursor' ? 'bg-canvas text-canvas-fg shadow-sm' : 'text-muted-fg hover:text-canvas-fg'"
@click="setMode('cursor')"
>
Forever
</button>
</div>
</div>
<DomDataGrid
v-model:filters="filters"
v-model:sort="sort"
v-model:search="search"
v-model:selected-keys="selectedKeys"
:rows="rows"
:columns="columns"
:loading="loading && !rows.length"
:total-rows="pageInfo.total"
remote
title="Customers"
resource-label="customers"
:height="gridHeight"
@query-change="handleQueryChange"
@viewport-end="loadNextCursorPage"
>
<template #toolbar-actions>
<span class="hidden rounded-md border border-border bg-canvas px-2.5 py-1.5 text-xs font-medium text-muted-fg lg:inline-flex">
{{ pageInfo.returned || rows.length }} returned / {{ pageInfo.total || 0 }} total
</span>
</template>
</DomDataGrid>
<DomPagination
v-if="mode === 'page'"
:page="page"
:page-size="pageSize"
:total="pageInfo.total"
:disabled="loading"
@update:page="loadResource({ page: $event })"
/>
<div v-else class="flex flex-col gap-3 rounded-lg border border-border bg-canvas p-4 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-muted-fg">
Loaded {{ rows.length }} of {{ pageInfo.total || 0 }} customers with cursor pagination.
</p>
<DomButton
variant="secondary"
size="sm"
:loading="loading"
:disabled="!pageInfo.hasNextPage"
@click="loadNextCursorPage"
>
Load next page
</DomButton>
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-border bg-canvas p-4">
<p class="text-sm font-semibold text-canvas-fg">Request</p>
<pre class="mt-3 max-h-72 overflow-auto rounded-md bg-secondary/40 p-3 text-xs leading-5 text-canvas-fg">{{ requestPreview }}</pre>
</div>
<div class="rounded-lg border border-border bg-canvas p-4">
<p class="text-sm font-semibold text-canvas-fg">Response</p>
<pre class="mt-3 max-h-72 overflow-auto rounded-md bg-secondary/40 p-3 text-xs leading-5 text-canvas-fg">{{ responsePreview }}</pre>
</div>
</div>
</div>
</template>
Custom cells
Column components and row actions
Set column.component to a Vue component. The component receives row, column, value, formatted, rowIndex, and rowKey. Display-only columns such as actions still use a stable key, but do not need a matching row property.
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 |
<script setup>
import { ref } from 'vue';
import { DomDataGrid } from '../../../lib/vue';
import EditNameCell from './EditNameCell.vue';
import RowActionsCell from './RowActionsCell.vue';
const rows = ref([
{ id: 'usr-1001', name: 'Maya Chen', email: 'maya@example.com', role: 'Product', status: 'pending', lastActive: '2026-06-17' },
{ id: 'usr-1002', name: 'Jon Bell', email: 'jon@example.com', role: 'Engineering', status: 'active', lastActive: '2026-06-16' },
{ id: 'usr-1003', name: 'Priya Shah', email: 'priya@example.com', role: 'Design', status: 'active', lastActive: '2026-06-12' },
{ id: 'usr-1004', name: 'Noah Reed', email: 'noah@example.com', role: 'Support', status: 'paused', lastActive: '2026-06-08' },
{ id: 'usr-1005', name: 'Amina Yusuf', email: 'amina@example.com', role: 'Operations', status: 'pending', lastActive: '2026-06-06' },
{ id: 'usr-1006', name: 'Leo Morgan', email: 'leo@example.com', role: 'Sales', status: 'active', lastActive: '2026-06-04' },
]);
const lastEvent = ref('Custom cell events will appear here.');
const columns = [
{
key: 'name',
label: 'User',
type: 'text',
width: '18rem',
component: EditNameCell,
},
{ key: 'role', label: 'Team', type: 'text', width: '10rem' },
{
key: 'status',
label: 'Status',
type: 'select',
width: '8rem',
options: [
{ label: 'Active', value: 'active', tone: 'green' },
{ label: 'Pending', value: 'pending', tone: 'amber' },
{ label: 'Paused', value: 'paused', tone: 'neutral' },
],
},
{ key: 'lastActive', label: 'Last active', type: 'date', width: '9rem' },
{
key: 'actions',
label: 'Actions',
width: '13rem',
align: 'right',
sortable: false,
filterable: false,
searchable: false,
component: RowActionsCell,
},
];
function handleCellUpdate(event) {
const target = rows.value.find((row) => row.id === event.row.id);
if (!target) return;
const field = event.column.field || event.column.key;
target[field] = event.value;
lastEvent.value = `Saved ${event.column.label.toLowerCase()} for ${target.id}: ${event.value}`;
}
function handleCellAction(event) {
const target = rows.value.find((row) => row.id === event.row.id);
if (!target) return;
if (event.action === 'approve') {
target.status = 'active';
lastEvent.value = `Approved ${target.name} (${target.id}).`;
return;
}
if (event.action === 'delete') {
rows.value = rows.value.filter((row) => row.id !== target.id);
lastEvent.value = `Deleted ${target.name} (${target.id}).`;
return;
}
lastEvent.value = `Viewing ${target.name} (${target.id}).`;
}
</script>
<template>
<div class="w-full min-w-0">
<DomDataGrid
:rows="rows"
:columns="columns"
:selectable="false"
:show-row-numbers="false"
:show-column-letters="false"
title="Team members"
resource-label="users"
height="22rem"
@cell-update="handleCellUpdate"
@cell-action="handleCellAction"
>
<template #toolbar-actions>
<span class="hidden rounded-md border border-border bg-canvas px-2.5 py-1.5 text-xs font-medium text-muted-fg lg:inline-flex">
{{ lastEvent }}
</span>
</template>
</DomDataGrid>
</div>
</template>
EditNameCell.vue
<script setup>
import { computed, ref, watch } from 'vue';
const props = defineProps({
value: {
type: [String, Number],
default: '',
},
formatted: {
type: String,
default: '',
},
row: {
type: Object,
required: true,
},
column: {
type: Object,
required: true,
},
rowIndex: {
type: Number,
required: true,
},
rowKey: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(['update:value']);
const editing = ref(false);
const draft = ref(String(props.value ?? ''));
const initials = computed(() => {
const source = String(props.value || props.row.email || '?').trim();
return source
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
});
watch(() => props.value, (value) => {
if (!editing.value) draft.value = String(value ?? '');
});
function startEdit() {
draft.value = String(props.value ?? '');
editing.value = true;
}
function save() {
const value = draft.value.trim();
if (value) emit('update:value', value);
editing.value = false;
}
function cancel() {
draft.value = String(props.value ?? '');
editing.value = false;
}
</script>
<template>
<div class="min-w-0" @click.stop>
<form v-if="editing" class="flex min-w-0 items-center gap-2" @submit.prevent="save">
<input
v-model="draft"
type="text"
class="skin-input h-8 min-w-0 rounded-md text-sm"
:aria-label="`Edit ${column.label} for row ${rowIndex + 1}`"
/>
<button
type="submit"
class="skin-action inline-flex h-8 items-center rounded-md px-2 text-xs font-semibold"
>
Save
</button>
<button
type="button"
class="inline-flex h-8 items-center rounded-md px-2 text-xs font-semibold text-muted-fg transition hover:bg-secondary hover:text-canvas-fg"
@click="cancel"
>
Cancel
</button>
</form>
<button
v-else
type="button"
class="group flex min-w-0 items-center gap-3 rounded-md py-1 pr-2 text-left outline-none transition hover:bg-secondary/70 focus-visible:ring-2 focus-visible:ring-ring/50"
:aria-label="`Edit ${formatted || value}`"
@click="startEdit"
>
<span class="grid size-8 shrink-0 place-items-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{{ initials }}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-canvas-fg">{{ formatted || value }}</span>
<span class="block truncate text-xs text-muted-fg">{{ row.email }}</span>
</span>
<span class="text-xs font-medium text-muted-fg opacity-0 transition group-hover:opacity-100 group-focus-visible:opacity-100">
Edit
</span>
</button>
</div>
</template>
RowActionsCell.vue
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
},
rowIndex: {
type: Number,
required: true,
},
rowKey: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(['action']);
function selectAction(action) {
emit('action', {
action,
id: props.row.id,
name: props.row.name,
rowIndex: props.rowIndex,
rowKey: props.rowKey,
});
}
</script>
<template>
<div class="flex justify-end gap-1" @click.stop>
<button
type="button"
class="rounded-md px-2 py-1 text-xs font-semibold text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
:aria-label="`View ${row.name}`"
@click="selectAction('view')"
>
View
</button>
<button
type="button"
class="rounded-md px-2 py-1 text-xs font-semibold text-emerald-700 transition hover:bg-emerald-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 dark:text-emerald-300"
:aria-label="`Approve ${row.name}`"
@click="selectAction('approve')"
>
Approve
</button>
<button
type="button"
class="rounded-md px-2 py-1 text-xs font-semibold text-destructive transition hover:bg-destructive/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
:aria-label="`Delete ${row.name}`"
@click="selectAction('delete')"
>
Delete
</button>
</div>
</template>
Architecture
Resource query API
The component filters client-side by default so it works immediately with local rows. Set remote when the grid should render API results exactly as supplied and use query-change to request a SQL-backed resource. Keep pagination beside the grid query so the same endpoint can support numbered pages, previous/next controls, and cursor-fed scrolling.
Use virtualRows when rows are already held in memory but the DOM should stay small. It swaps the table body to a dedicated virtual row renderer, using virtualRowBuffer to keep extra rows mounted above and below the viewport.
For custom rendering, add component to a column definition. The column key is the stable column id; set field when the component should read a different row property, or omit it for display-only columns such as row actions.
Column definitions are the contract. A date column gets a date-range filter, numeric columns get min/max, boolean columns get a yes/no segmented control, and select columns can render known options from the resource schema. Set filterable: false on display-only or operational columns so they stay out of the filter UI and resource query.
For more complex ordering, pass sort.rules as an ordered array, or enable multiSort so header clicks build that array interactively. A keyword table can send searchVolume desc followed by difficulty asc, and a resource API can translate that directly to a multi-column ORDER BY.
// 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,
},
}
// Same endpoint for forever scrolling.
{
resource: 'customers',
query,
pagination: {
strategy: 'cursor',
cursor: 'customer:25',
limit: 25,
},
}
// Response
{
data: [
/* normalized customer rows */
],
pageInfo: {
strategy: 'page',
page: 2,
pageSize: 25,
pageCount: 8,
total: 184,
returned: 25,
hasNextPage: true,
hasPreviousPage: true,
nextCursor: 'customer:50',
},
meta: {
resource: 'customers',
requestedAt: '2026-06-17T10:00:00.000Z',
total: 184,
returned: 25,
query,
},
}Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
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. |
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 }) | Re-emitted when a column component emits update:value. |
| @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. |
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 } | Extra controls rendered next to search and Clear filters. |
| #empty | — | Custom empty state. |