Most Vue projects hit the same wall sooner or later: a plain <table> stops being enough. You need sorting, a search box, per-column filters, grouping with totals, inline editing with validation, an export button, and eventually a table too big to load at once.
I've been building inandu-grid-vue, a Vue 3 data grid that covers that list without an enterprise tier. It's MIT-licensed, ships as a single component plus a headless composable, and is about 19 KB gzipped (JS) plus 2 KB of CSS, with vue as its only peer dependency. This post walks through how it works, with code you can paste.
- Live demo: https://inandusolutions.github.io/inandu-grid-vue/
- Edit it in StackBlitz: https://stackblitz.com/github/inandusolutions/inandu-grid-vue/tree/main/examples/stackblitz
-
npm:
@inandu-solutions/grid-vue - Source: https://github.com/inandusolutions/inandu-grid-vue
Install and render a grid
npm install @inandu-solutions/grid-vue
<script setup lang="ts">
import { InanduGrid, type InanduGridColumn } from '@inandu-solutions/grid-vue';
import '@inandu-solutions/grid-vue/style.css';
const rows = [{ name: 'Ada', age: 36 }, { name: 'Alan', age: 41 }];
const columns: InanduGridColumn[] = [
{ field: 'name', headerText: 'Name' },
{ field: 'age', headerText: 'Age', type: 'number' },
];
</script>
<template>
<!-- sorting + filtering are on by default -->
<InanduGrid :rows="rows" :columns="columns" :page-size="25" />
</template>
Columns are plain objects. type (string, number, date, boolean) decides the filter control, the formatting and the sort rule, so a number column gets a min/max filter and a date column gets a from/to range without extra code. The default stylesheet is optional; skip the import and bring your own CSS if you prefer.
Grouping and aggregates
Declare an aggregate on a column and pick a column in the grid's group-by control. You get per-group sum / avg / min / max / count and a grand total row:
const columns: InanduGridColumn[] = [
{ field: 'team', headerText: 'Team' },
{ field: 'salary', headerText: 'Salary', type: 'number', format: '1.0-0', aggregate: 'avg' },
];
Inline editing, without the grid owning your data
Mark columns editable, add creatable / deletable, and the grid renders the edit controls and validates at save time (required, min, max, pattern, a custom validator, and asyncValidator).
The design choice I care most about: the grid never mutates rows. Events hand you the change, and you decide what to do with it (update local state, call an API, roll back):
<script setup lang="ts">
import { shallowRef } from 'vue';
import { InanduGrid, type InanduGridColumn, type InanduGridRowSave } from '@inandu-solutions/grid-vue';
const rows = shallowRef([{ id: 1, name: 'Ada', salary: 90000 }]);
const columns: InanduGridColumn[] = [
{ field: 'name', headerText: 'Name', editable: true, required: true },
{ field: 'salary', headerText: 'Salary', type: 'number', editable: true, min: 0 },
];
function onRowSave({ row, values }: InanduGridRowSave) {
rows.value = rows.value.map(r => (r === row ? { ...r, ...values } : r));
}
</script>
<template>
<InanduGrid :rows="rows" :columns="columns" deletable creatable @row-save="onRowSave" />
</template>
If you've ever fought a component that edits your array behind your back, this is the opposite. There are matching row-create, row-delete and rows-delete events.
Slots for cells, headers, editors and detail rows
Scoped slots replace the built-in rendering per column (#cell-<field>, #header-<field>, #editor-<field>), and a #detail slot adds an expandable master-detail row. Sorting, resizing and validation keep working:
<InanduGrid :rows="orders" :columns="columns">
<template #cell-status="{ value }">
<StatusBadge :status="value" />
</template>
<template #detail="{ row }">
<OrderLines :order="row" />
</template>
</InanduGrid>
If you'd rather work in render functions, each column also accepts renderCell, renderHeader and renderEditor.
Ten thousand rows: virtual scroll
Pagination is the default, but for long lists you can render only what's visible:
<InanduGrid
:rows="tenThousandRows"
:columns="columns"
virtual-scroll
:virtual-row-height="40"
:height="400"
/>
You give it a fixed row height and a viewport height. The row height isn't auto-measured, which keeps the scroll math simple and predictable. Sorting, search and filters still work over the full set.
Server-side mode
When the table is too big to load, let the backend sort, filter and page while the UI stays the same. useInanduGridServer turns the grid's state into a query string, aborts requests that have been superseded, debounces typing, and keeps the previous page on screen while the next one loads:
<script setup lang="ts">
import { InanduGrid, useInanduGridServer } from '@inandu-solutions/grid-vue';
const server = useInanduGridServer({ url: '/api/products' });
</script>
<template>
<InanduGrid
:rows="server.rows.value"
:columns="columns"
:page-size="25"
:server-side="{ totalItems: server.total.value }"
@query-change="server.setQuery"
/>
</template>
The request looks like /api/products?page=2&pageSize=25&sort=-price&q=cable&price_gte=20 and the response is { data: [...], total: n }. The request contract is plain query-string parameters, so any backend can implement it. If yours is ASP.NET Core with EF Core, there's a companion package that does it in one call, but nothing on the Vue side depends on it.
Headless: just the state
If you want your own markup, useInanduGrid() gives you the whole engine without any DOM. Pass a getter so it follows reactive data; every piece of state is a reactive getter, so there's no .value:
const headless = useInanduGrid(() => ({ rows: rows.value, columns, pageSize: 25 }));
headless.visibleRows; // the current page, already sorted and filtered
headless.toggleSort('name'); // drive it from your own UI
The component itself exposes the same engine on a template ref (gridRef.value?.grid.toggleSort('name')) for programmatic control.
The rest of the list
Briefly, because it's all in the README:
-
Export: CSV, Excel (
.xls) and PDF, plus print, from oneexportableflag.jspdfis loaded lazily, so you only pay for the PDF path if someone uses it. - Columns: drag-resize, double-click to autosize, drag-reorder, pin left or right, show/hide at runtime.
-
Selection: row selection, Excel-style cell range selection (
cell-range-selection,multi-range), and clipboard copy/paste as TSV. -
Tree data: point
tree-children-keyat the property holding each row's children. -
i18n: English, Spanish, French, Italian and Chinese are bundled; switch with
lang="es". -
TypeScript: props, events and column definitions are typed (
InanduGridColumn,InanduGridRowSave, ...).
Where it comes from
The framework-agnostic core (sorting, filtering, aggregation, export, i18n) is ported from an Angular grid, @inandu-solutions/grid-angular, and kept in sync by hand across the Angular, React and Vue repos, so a fix to the core lands in all three. The Vue layer is where the Vue-specific parts live: the component, SFC slots, emitted events and the composables.
Try it
- Play with the live demo; every tab is the real component.
- Open the StackBlitz example and edit
src/App.vue. -
npm install @inandu-solutions/grid-vue, then see the README for the full API.
It's MIT, issues and PRs are welcome on GitHub, and I'd like to hear which feature you hit first that the grid doesn't cover.


Top comments (0)