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

Status: Active, TrialRenewal: 2026-07-01 to 2026-09-30
ABCDEFGHIJKL
#

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

1Anchor CloudActiveEnterpriseSofia Jensen264£310,000StrongNA31 Jul 2026240Yes11 Jun 2026
2Orchid HealthActiveEnterpriseLaura Chen232£268,000StrongNA22 Sept 2026211Yes11 Jun 2026
3Forge RoboticsActiveEnterprisePriya Singh188£224,500StrongAPAC04 Sept 2026169Yes10 Jun 2026
4Mercury MediaActiveEnterpriseCaleb Stone176£201,300StrongNA09 Jul 2026151Yes09 Jun 2026
5Northstar AnalyticsActiveEnterpriseAmelia Hart142£184,000StrongEMEA19 Aug 2026118Yes10 Jun 2026
6Signal HarborActiveScaleNora Ellis96£112,900StrongEMEA02 Aug 202688Yes07 Jun 2026
7Atlas FinanceActiveScaleRavi Patel84£96,500WatchNA28 Jul 202662Yes08 Jun 2026
8Sable SecurityActiveScaleElena Rossi72£88,900StrongEMEA29 Sept 202670Yes02 Jun 2026
9Violet ManufacturingTrialScaleKai Foster58£65,400WatchAPAC24 Aug 202643No01 Jun 2026
10Summit GroceryActiveGrowthMaeve Walsh36£41,800StrongNA16 Sept 202634Yes06 Jun 2026
11Bluejay TravelTrialGrowthTara Lewis22£24,800WatchLATAM19 Jul 202617No04 Jun 2026
12Brightwell StudioTrialGrowthMina Okafor18£21,400StrongEMEA14 Sept 202616No09 Jun 2026
13Mosaic CivicActiveStarterGeorge White16£15,800StrongEMEA11 Sept 202615Yes10 Jun 2026
14Kite EducationTrialStarterOwen Price14£11,800StrongEMEA31 Aug 202613No06 Jun 2026
15Pioneer RetailTrialStarterMiles Brooks9£7,800WatchNA12 Jul 20266No03 Jun 2026
12 columns2 selected

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

ABCDEFGH
#

Filter Event

Filter Account

Filter Service

Values

Filter Status

Values

Filter Region

Values

Filter Requests

Filter Spend

Filter Updated

11Atlas Works 1Billing APIHealthyUK2,500£150.0001 Jan 2026
22Northstar Labs 1MailDelayedEU2,597£187.0002 Feb 2026
33Pioneer Health 1Billing APIQueuedUS2,694£224.0003 Mar 2026
44Evergreen Finance 1MailHealthyCA2,791£261.0004 Apr 2026
55Lunar Retail 1Billing APIHealthyAU2,888£298.0005 May 2026
66Cobalt Systems 1MailFailedUK2,985£335.0006 Jun 2026
77Atlas Works 2Billing APIHealthyEU3,082£372.0007 Jan 2026
88Northstar Labs 2MailDelayedUS3,179£409.0008 Feb 2026
99Pioneer Health 2Billing APIQueuedCA3,276£446.0009 Mar 2026
1010Evergreen Finance 2MailHealthyAU3,373£483.0010 Apr 2026
1111Lunar Retail 2Billing APIHealthyUK3,470£520.0011 May 2026
1212Cobalt Systems 2MailFailedEU3,567£557.0012 Jun 2026
1313Atlas Works 3Billing APIHealthyUS3,664£594.0013 Jan 2026
1414Northstar Labs 3MailDelayedCA3,761£631.0014 Feb 2026
1515Pioneer Health 3Billing APIQueuedAU3,858£668.0015 Mar 2026
1616Evergreen Finance 3MailHealthyUK3,955£705.0016 Apr 2026
1717Lunar Retail 3Billing APIHealthyEU4,052£742.0017 May 2026
1818Cobalt Systems 3MailFailedUS4,149£779.0018 Jun 2026
1919Atlas Works 4Billing APIHealthyCA4,246£816.0019 Jan 2026
2020Northstar Labs 4MailDelayedAU4,343£853.0020 Feb 2026
2121Pioneer Health 4Billing APIQueuedUK4,440£890.0021 Mar 2026
2222Evergreen Finance 4MailHealthyEU4,537£927.0022 Apr 2026
2323Lunar Retail 4Billing APIHealthyUS4,634£964.0023 May 2026
2424Cobalt Systems 4MailFailedCA4,731£1,00124 Jun 2026
2525Atlas Works 5Billing APIHealthyAU4,828£1,03825 Jan 2026
2626Northstar Labs 5MailDelayedUK4,925£1,07526 Feb 2026
2727Pioneer Health 5Billing APIQueuedEU5,022£1,11227 Mar 2026
2828Evergreen Finance 5MailHealthyUS5,119£1,14928 Apr 2026
2929Lunar Retail 5Billing APIHealthyCA5,216£1,18601 May 2026
3030Cobalt Systems 5MailFailedAU5,313£1,22302 Jun 2026
3131Atlas Works 6Billing APIHealthyUK5,410£1,26003 Jan 2026
3232Northstar Labs 6MailDelayedEU5,507£1,29704 Feb 2026
3333Pioneer Health 6Billing APIQueuedUS5,604£1,33405 Mar 2026
3434Evergreen Finance 6MailHealthyCA5,701£1,37106 Apr 2026
3535Lunar Retail 6Billing APIHealthyAU5,798£1,40807 May 2026
3636Cobalt Systems 6MailFailedUK5,895£1,44508 Jun 2026
3737Atlas Works 7Billing APIHealthyEU5,992£1,48209 Jan 2026
3838Northstar Labs 7MailDelayedUS6,089£1,51910 Feb 2026
3939Pioneer Health 7Billing APIQueuedCA6,186£1,55611 Mar 2026
4040Evergreen Finance 7MailHealthyAU6,283£1,59312 Apr 2026
4141Lunar Retail 7Billing APIHealthyUK6,380£1,63013 May 2026
4242Cobalt Systems 7MailFailedEU6,477£1,66714 Jun 2026
4343Atlas Works 8Billing APIHealthyUS6,574£1,70415 Jan 2026
4444Northstar Labs 8MailDelayedCA6,671£1,74116 Feb 2026
4545Pioneer Health 8Billing APIQueuedAU6,768£1,77817 Mar 2026
4646Evergreen Finance 8MailHealthyUK6,865£1,81518 Apr 2026
4747Lunar Retail 8Billing APIHealthyEU6,962£1,85219 May 2026
4848Cobalt Systems 8MailFailedUS7,059£1,88920 Jun 2026
4949Atlas Works 9Billing APIHealthyCA7,156£1,92621 Jan 2026
5050Northstar Labs 9MailDelayedAU7,253£1,96322 Feb 2026
5151Pioneer Health 9Billing APIQueuedUK7,350£2,00023 Mar 2026
8 columnsReady
<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

ABCDEF
#

Filter Keyword

Filter Intent

Values

Filter Volume

Filter Difficulty

Filter Opportunity

Filter CPC

1vue data gridCommercial5,400283,942$5.40
2javascript spreadsheet componentComparison5,400433,132$7.10
3editable table vueCommercial2,900212,320$4.80
4tailwind data tableInformational8,100613,240$3.90
5server side data gridCommercial3,600352,376$8.20
6open source data grid vueComparison4,400243,388$4.60
7keyword clustering toolTransactional6,600483,498$6.70
8saas dashboard tableInformational1,900171,596$3.20
9best vue table libraryComparison5,400313,780$5.90
10crm data gridCommercial2,400191,968$9.40
6 columnsReady
{
  "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

ABCDEFG
#

Filter Customer

Filter Status

Values

Filter Owner

Filter Region

Values

Filter Seats

Filter ARR

Filter Updated

No rows match the current filters.
7 columnsReady

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

ProductPending17 Jun 2026
EngineeringActive16 Jun 2026
DesignActive12 Jun 2026
SupportPaused08 Jun 2026
OperationsPending06 Jun 2026
SalesActive04 Jun 2026
5 columnsReady
<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

NameTypeTSDefaultDescription
rowsarrayArray<unknown>[]Rows to render. Each row should be a plain object from an API, store, or database result.
columnsarrayArray<DataGridColumn
type DataGridColumn = {
	key: string; // Stable column id. It can match a row property, or identify a display-only column such as actions.
	field?: string; // Row property to read when it differs from key.
	label?: string; // Header label.
	type?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'currency'; // Column data type. Drives sorting, formatting, and filter controls.
	width?: string | number; // CSS width for the column.
	align?: 'left' | 'center' | 'right'; // Cell alignment.
	component?: object | Function | string; // Vue component used to render cells in this column. Receives row, column, value, formatted, rowIndex, and rowKey props.
	resizable?: boolean; // Set false to disable drag resizing for this column.
	sortable?: boolean; // Set false to disable sorting.
	filterable?: boolean; // Set false to disable filtering for this column, hide the filter button, and omit the column from query filters.
	searchable?: boolean; // Set false to remove the column from global search.
	options?: Array<string | number | boolean | object>; // Select filter options. Objects may include label, value, and tone.
	currency?: string; // Currency code for currency columns.
	accessor?: Function; // Function that receives a row and returns the cell value.
	sortAccessor?: Function; // Function that receives a row and column and returns the value used for local sorting.
	format?: Function; // Function that receives value, row, and column and returns display text.
};
>
[]Column definitions. Each type chooses its own sorting, formatting, and filter controls.
rowKeystring | functionstring'id'Field name or function used to identify each row.
filtersobjectRecord<string, unknown>{}Controlled filter map keyed by column key.
sortobject | arrayArray<unknown>{"key":"","direction":""}Controlled sort state as { key, direction }, or { rules } for multi-column and computed sorting.
multiSortbooleanbooleanfalseWhen true, header clicks toggle each sortable column inside an ordered sort.rules array.
searchstringstring''Global search text.
selectedKeysarrayArray<unknown>[]Selected row keys.
remotebooleanbooleanfalseWhen true, rows render as supplied and query-change can drive a server/API request.
loadingbooleanbooleanfalseShow a loading row while server data is being fetched.
virtualRowsbooleanbooleanfalseRender large in-memory row sets through the isolated virtual row renderer.
virtualRowBuffernumbernumber50Rows kept mounted above and below the visible viewport when virtualRows is true.
virtualRowHeightnumbernumberFixed row height in pixels for virtual row calculations. Defaults to the grid density.
selectablebooleanbooleantrueShow row selection checkboxes.
showRowNumbersbooleanbooleantrueShow spreadsheet-style row numbers.
showColumnLettersbooleanbooleantrueShow spreadsheet-style column letters above headers.
stickyHeaderbooleanbooleantrueKeep header rows visible while the grid scrolls.
resizableColumnsbooleanbooleantrueAllow users to drag header dividers to resize columns.
minColumnWidthnumbernumber40Minimum column width in pixels while resizing. The default leaves room for the filter icon.
toolbarbooleanbooleantrueShow the title, search box, filter chips, and row count toolbar.
titlestringstring'Data grid'Toolbar title.
resourceLabelstringstring'rows'Human label for the resource being rendered.
totalRowsnumbernumberServer-side total row count when remote is true.
density'compact' | 'comfortable'string'compact'Row height density.
heightstring | numberstring'34rem'Scrollable grid height as a CSS size or pixel number.
emptyTextstringstring'No rows match the current filters.'Message shown when no rows are visible.

Auto-generated from Data grid.props and inline _edit hints.

Events

NamePayloadDescription
@update:filtersRecord<string, DataGridFilter>Fired when a column filter is applied or cleared.
@filter-change(payload
payload: unknown;
: unknown)
@update:sortDataGridSortFired when column sorting changes. Complex sort values may include an ordered rules array.
@sort-change(payload
payload: unknown;
: unknown)
@update:searchstringFired when the toolbar search changes.
@query-changeDataGridQueryFired 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:selectedKeysArray<string | number>Fired when row selection changes.
@selection-change(payload
payload: unknown;
: unknown)
@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

NameScopeDescription
#cell{ row, column, value, formatted, rowIndex, rowKey }Custom cell renderer.
#toolbar-actions{ query, clearFilters }Extra controls rendered next to search and Clear filters.
#emptyCustom empty state.