vGrid Documentation
vGrid is a JavaScript data grid in one script and one stylesheet, with no dependencies. You give vGrid({ … }) a container and a URL; your server pages, sorts and filters, and the grid shows the result.
vGrid({
el: '#orders',
dataSource: '/api/orders', // your endpoint
rowsPerPage: 25, // sent as limit=25
columns: [
{ name: 'number', label: 'Order' },
{ name: 'total', label: 'Total' }
]
});
This page takes you from that call to a grid over your own database, thousands or millions of rows. See one working in the Products example: sort by Price, filter it, then open a Sales panel.
Download
Two files run the grid: vgrid.js and vgrid.css. Use the minified pair in production.
| File | This site |
|---|---|
vgrid.min.js | Download |
vgrid.min.css | Download |
vgrid.js, readable | Download |
vgrid.css, readable | Download |
Or load them from the jsDelivr CDN, which serves the latest code on GitHub:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/virgil2304/vGrid@main/dist/vgrid.min.css">
<script src="https://cdn.jsdelivr.net/gh/virgil2304/vGrid@main/dist/vgrid.min.js"></script>
Installation
Add vGrid to any HTML page in three steps.
Include vGrid
Load the two files from the CDN, or from your own copy (Download). The script defines one global function, vGrid.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/virgil2304/vGrid@main/dist/vgrid.min.css">
<script src="https://cdn.jsdelivr.net/gh/virgil2304/vGrid@main/dist/vgrid.min.js"></script>
Add a target element
Any element works: a <table> is used as is; anything else gets a table inside it.
<div id="tasks"></div>
Initialize the grid
Call vGrid once the element exists.
<script>
document.addEventListener('DOMContentLoaded', () => {
vGrid({
el: '#tasks',
columns: [
{ name: 'task', label: 'Task' },
{ name: 'owner', label: 'Owner' }
],
data: [
{ task: 'Draft release notes', owner: 'Ava' },
{ task: 'Review pull requests', owner: 'Noah' }
]
});
});
</script>
The result is a sortable, filterable grid with row numbers and a toolbar. Try it in the Local example: filter Status, then sort by Owner.
Requirements and browser support
No dependencies, no framework, no build step. vGrid needs a browser with the Popover API:
| Browser | Minimum |
|---|---|
| Chrome, Edge | 114 |
| Firefox | 125 |
| Safari, and every browser on iOS / iPadOS | 17 |
| Opera | 100 |
| Samsung Internet | 23 |
In older browsers the rows still load, but the settings popup, filter-mode menu and subgrid menus don't open. Internet Explorer, legacy Edge and Opera Mini are not supported.
Integration guide
From nothing to a grid your server pages, sorts and filters. Start with the two tags from Include vGrid and a container:
<div id="orders"></div>
The grid calls your URL with these query parameters:
GET /api/orders?page=2&limit=25&sort=total&dir=desc&status=paid
Your endpoint filters, then sorts, then counts, and returns one page:
{
"data": [ { "number": "A-1001", "customer": "Ava", "status": "paid", "total": 120 } ],
"total": 1834,
"page": 2,
"limit": 25
}
On the server, in your language: connect, read one page, then turn each row into an object and return them in the shape above. The last part is what vGrid needs; the connection and query are yours.
<?php
$db = new PDO('mysql:host=localhost;dbname=shop', 'user', 'password');
$page = (int) ($_GET['page'] ?? 1);
$limit = (int) ($_GET['limit'] ?? 25);
$total = (int) $db->query('SELECT COUNT(*) FROM orders')->fetchColumn();
$rows = $db->prepare('SELECT * FROM orders LIMIT :limit OFFSET :offset');
$rows->bindValue(':limit', $limit, PDO::PARAM_INT);
$rows->bindValue(':offset', ($page - 1) * $limit, PDO::PARAM_INT);
$rows->execute();
$data = [];
foreach ($rows->fetchAll(PDO::FETCH_ASSOC) as $row) {
$data[] = [ // one object per row
'number' => $row['number'], // key = columns[].name
'customer' => $row['customer'],
'status' => $row['status'],
'total' => (float) $row['total'],
];
}
header('Content-Type: application/json');
echo json_encode([ // the shape vGrid reads
'data' => $data,
'total' => $total, // all matching rows, not this page
'page' => $page,
'limit' => $limit,
]);
import sqlite3
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.get('/api/orders')
def orders():
db = sqlite3.connect('shop.db')
db.row_factory = sqlite3.Row
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 25))
total = db.execute('SELECT COUNT(*) FROM orders').fetchone()[0]
rows = db.execute('SELECT * FROM orders LIMIT ? OFFSET ?',
(limit, (page - 1) * limit))
data = []
for row in rows:
data.append({ # one object per row
'number': row['number'], # key = columns[].name
'customer': row['customer'],
'status': row['status'],
'total': row['total'],
})
return jsonify( # the shape vGrid reads
data=data,
total=total, # all matching rows, not this page
page=page,
limit=limit,
)
using Microsoft.Data.SqlClient;
var app = WebApplication.Create(args);
app.MapGet("/api/orders", async (int page = 1, int limit = 25) =>
{
await using var db = new SqlConnection(
"Server=localhost;Database=shop;Integrated Security=true");
await db.OpenAsync();
var count = new SqlCommand("SELECT COUNT(*) FROM orders", db);
var total = (int)(await count.ExecuteScalarAsync())!;
var query = new SqlCommand("SELECT * FROM orders ORDER BY id " +
"OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY", db);
query.Parameters.AddWithValue("@offset", (page - 1) * limit);
query.Parameters.AddWithValue("@limit", limit);
var data = new List<object>();
await using var rows = await query.ExecuteReaderAsync();
while (await rows.ReadAsync())
{
data.Add(new // one object per row
{
number = rows["number"], // key = columns[].name
customer = rows["customer"],
status = rows["status"],
total = rows["total"]
});
}
return Results.Json(new // the shape vGrid reads
{
data,
total, // all matching rows, not this page
page,
limit
});
});
app.Run();
// application.properties
// spring.datasource.url=jdbc:mysql://localhost/shop
// spring.datasource.username=user
// spring.datasource.password=password
@RestController
public class OrdersController {
private final JdbcTemplate db;
public OrdersController(JdbcTemplate db) {
this.db = db;
}
@GetMapping("/api/orders")
public Map<String, Object> orders(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "25") int limit) {
Integer total = db.queryForObject(
"SELECT COUNT(*) FROM orders", Integer.class);
List<Map<String, Object>> rows = db.queryForList(
"SELECT * FROM orders LIMIT ? OFFSET ?", limit, (page - 1) * limit);
List<Map<String, Object>> data = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = new LinkedHashMap<>(); // one object per row
item.put("number", row.get("number")); // key = columns[].name
item.put("customer", row.get("customer"));
item.put("status", row.get("status"));
item.put("total", row.get("total"));
data.add(item);
}
Map<String, Object> response = new LinkedHashMap<>(); // the shape vGrid reads
response.put("data", data);
response.put("total", total); // all matching rows, not this page
response.put("page", page);
response.put("limit", limit);
return response;
}
}
import express from 'express';
import mysql from 'mysql2/promise';
const db = await mysql.createConnection({
host: 'localhost', user: 'user', password: 'password', database: 'shop'
});
const app = express();
app.get('/api/orders', async (req, res) => {
const page = Number(req.query.page ?? 1);
const limit = Number(req.query.limit ?? 25);
const [[{ total }]] = await db.query('SELECT COUNT(*) AS total FROM orders');
const [rows] = await db.query('SELECT * FROM orders LIMIT ? OFFSET ?',
[limit, (page - 1) * limit]);
const data = [];
for (const row of rows) {
data.push({ // one object per row
number: row.number, // key = columns[].name
customer: row.customer,
status: row.status,
total: row.total
});
}
res.json({ // the shape vGrid reads
data,
total, // all matching rows, not this page
page,
limit
});
});
app.listen(3000);
Each filled filter arrives as a parameter named after its column, and a sort as sort and dir; add them to your query's WHERE and ORDER BY.
The public endpoints of this site are in the API reference. Then point the grid at your endpoint:
vGrid({
el: '#orders',
dataSource: '/api/orders',
rowsPerPage: 25, // sent as limit=25
columns: [
{ name: 'number', label: 'Order' },
{ name: 'customer', label: 'Customer' },
{ name: 'status', label: 'Status' },
{ name: 'total', label: 'Total' }
]
});
You see the first 25 rows, "Showing 1–25 of 1834" and a pager. Typing in a filter sends one request after you pause; watch it in your browser's Network tab. A message in place of the rows is your server's answer, for example "The server answered 500" or "The response has no "data" field".
See it working in the Ajax example: filter Name, then sort by Code.
Inside a framework app the same rules always apply:
- The framework owns the container; vGrid owns everything inside it. Never let the framework render or clear its children.
- Create the grid after the container is on the page, and call
destroy()when it leaves; otherwise listeners, observers and requests leak. - The config is read once. A new config object rebuilds the grid, so keep it stable (
useMemo,computed) and use the instance API (reload(),setPage(),sort()) for everyday changes. - Import the stylesheet once, globally. Component-scoped CSS (Vue
scoped, CSS modules, Angular encapsulation) doesn't reach the grid's elements. - React and Vue are optional; install only the one your adapter needs.
Install with npm
vGrid isn't on the npm registry yet; npm installs it from GitHub. Append #<commit> to pin one commit.
npm install github:virgil2304/vGrid
| Import | Gives you |
|---|---|
vgrid | The vGrid function, as ES module and CommonJS |
vgrid/style.css | The stylesheet |
vgrid/react | The React component VGRID |
vgrid/vue | The Vue component vGrid |
vgrid/browser | vGrid, also set as globalThis.vGrid for code that expects the global |
vgrid/dist/* | The files from Download |
With a bundler:
import { vGrid } from 'vgrid';
import 'vgrid/style.css';
const grid = vGrid({
el: '#orders',
dataSource: '/api/orders',
rowsPerPage: 25,
columns: [
{ name: 'number', label: 'Order' },
{ name: 'total', label: 'Total' }
]
});
// when the container leaves the page; vGrid() returns null on a bad config
grid?.destroy();
With CommonJS (Jest and other require-based tools): const { vGrid } = require('vgrid');. Any bundler that reads package.json exports works: Vite, webpack 5, Rollup, esbuild, Parcel 2.
Mounting and identity
Where the grid goes, its caption, and the name that tells it apart from other grids on the page.
| Option | Accepts | Default | Effect |
|---|---|---|---|
el | selector or Element | required | The container. A <table> is used as is; any other element has its contents replaced by a new table. Nothing found stops the grid (Troubleshooting). |
caption | HTML string | none | A row at the top of the table, beside the toolbar buttons. Inserted as HTML. |
namespace | string | el's id | Adds <namespace>-<part> beside every vgrid-<part> class, and <namespace>-row-<id> on rows that have an id field. Also the fallback file name, <namespace>-<date>, when your server sends none, and the print title. |
autoDestroy | true | false | false | Destroys the grid on the next window scroll or resize after its container leaves the page. For pages that remove grids without calling destroy(). |
Two grids on one page:
vGrid({
el: '#orders',
caption: 'Orders',
dataSource: '/api/orders',
rowsPerPage: 25,
columns: [{ name: 'number', label: 'Order' }, { name: 'total', label: 'Total' }]
});
vGrid({
el: document.querySelector('#returns'),
caption: '<strong>Returns</strong>', // HTML
namespace: 'returns-grid', // instead of the id "returns"
autoDestroy: true, // this panel can be removed
dataSource: '/api/returns',
rowsPerPage: 25,
columns: [{ name: 'number', label: 'Return' }]
});
The second grid's rows carry returns-grid-row-<id>, and its downloads fall back to the name returns-grid-<date> when your server sends none. Target one grid by its container, or one record by its row class:
#orders .vgrid-caption { /* the Orders grid's caption only */ }
.returns-grid-row-42 { /* the return with id 42 */ }
See the caption and the namespace in the Local example: its caption row carries vgrid-caption local-grid-caption.
Data
Rows come from an array in the page (data) or from your server (dataSource). With dataSource set, data is ignored.
Local data
| Option | Accepts | Default | Effect |
|---|---|---|---|
data | array of row objects | [] | Rows held in the browser; the grid sorts, filters and pages them itself. |
const grid = vGrid({
el: '#tasks',
data: tasks, // [{ task: 'Draft release notes', owner: 'Ava' }, …]
rowsPerPage: 10,
columns: [{ name: 'task', label: 'Task' }, { name: 'owner', label: 'Owner' }]
});
grid.reload(newTasks); // new rows, back to page 1
Every row lives in the page, so local data suits small sets; large ones belong on the server. Try it in the Local example.
Remote data
The browser holds one page of rows; every sort, filter and page change is a request to your server.
| Option | Accepts | Default | Effect |
|---|---|---|---|
dataSource | URL | none | Makes the grid remote. A query string on the URL is sent with every request; the grid's own parameters win on a clash. |
method | 'GET' | 'POST' | 'GET' | GET sends the parameters in the query string; POST sends them as a JSON body. |
headers | object | {} | Extra headers on every request, such as an auth token. Subgrid panels get them too. |
vGrid({
el: '#orders',
dataSource: '/api/orders?region=eu', // region=eu goes with every request
headers: { Authorization: 'Bearer ' + token },
rowsPerPage: 25,
columns: [{ name: 'number', label: 'Order' }, { name: 'total', label: 'Total' }]
});
To send the parameters as a JSON body instead:
vGrid({
el: '#orders',
dataSource: '/api/orders',
method: 'POST', // body: {"page":1,"limit":25}
rowsPerPage: 25,
columns: [{ name: 'number', label: 'Order' }, { name: 'total', label: 'Total' }]
});
See a remote grid in the Ajax example: open your browser's Network tab, then sort or page.
The request
Every load, sort, filter and page change sends these parameters: in the query string for GET, in the JSON body for POST.
| Parameter | Sent when | Example |
|---|---|---|
page | always | page=2 |
limit | a page size is set | limit=25 |
sort, dir | a column is sorted; with a secondary sort (Shift+activate a heading) both are comma-separated lists in priority order | sort=total&dir=descsort=status,total&dir=asc,desc |
columns | some columns are hidden; lists the visible ones | columns=number,total |
| a column name | its filter has a value | status=paid |
| a filter-mode key | a filter mode is chosen; a range sends each filled end under its own key | from_date=2026-01-01 |
an externalFilters key | that page input has a value | region=eu |
download | a server download | download=true |
lang | always; the reader's language, empty when none is found (Language) | lang=de-AT |
GET /api/orders?page=2&limit=25&sort=total&dir=desc&columns=number,total&status=paid&lang=en
POST /api/orders
{ "page": 2, "limit": 25, "sort": "total", "dir": "desc", "columns": "number,total", "status": "paid", "lang": "en" }
Empty filters are left out. Typing waits for a 300 ms pause, and a new request cancels the one still running.
The response
{
"data": [ { "number": "A-1026", "total": 120 } ],
"total": 1834,
"page": 2,
"limit": 25
}
| Field | Required | Effect |
|---|---|---|
data | yes | The rows, keyed by columns[].name. Anything but an array shows an error in place of the rows. |
total | no; defaults to data.length | Rows matching the filters, across all pages. Drives "Showing X–Y of Z" and the page count. |
page | no; defaults to the requested page | The page shown. Send it to correct a page that no longer exists. |
limit | no | The page size. Without it there is no pager and no "Showing" line. |
See a live response in the Ajax example: in the Network tab, open the request to /api/continents.
Requests, security and reserved names
On a page with a csrf-token meta tag, every request to the same origin carries it as an X-CSRF-TOKEN header: rows, downloads and subgrid panels. A token you set in headers wins; requests to other origins never get it.
<meta name="csrf-token" content="…">
These names carry paging and sorting, so no column, externalFilters key or filter-mode key may use them, in any letter case: page, limit, sort, dir, offset, cursor, perpage, pagesize, sortby, sortorder, columns.
{ name: 'sort', label: 'Sort order' } // stops the grid
{ name: 'sort_order', label: 'Sort order' } // works
A column or external filter with a reserved name stops the grid: vGrid() returns null and the console names it. A filter-mode key with one is dropped. See Troubleshooting.
Slow, empty and broken responses
While a request runs, the rows dim at once; after 200 ms a "Loading…" badge appears over them. An empty data array shows "No records to show."
When a request fails, the rows stay and the badge shows the reason, announced to screen readers:
| Cause | Message |
|---|---|
| Status other than 2xx | The server answered <status>. |
| Body is not JSON | The response is not valid JSON. |
Body is an array or null | The response is not an object carrying a "data" array. |
No data | The response has no "data" field. |
data is not an array | The "data" field is not an array. |
The next successful request clears it. The error also reaches onReady as { error } and the console as vGrid fetch error:. Style the badge and the empty row:
.vgrid-loading-badge { /* the Loading… badge */ }
.vgrid-loading-badge.vgrid-loading-error { /* the badge showing an error */ }
.vgrid-empty td { /* the "No records to show." row */ }
Watch the badge in the Ajax example: set your browser's network throttling to a slow connection, then change page.
Server totals
Shows values your server computes over the whole filtered result, such as a revenue total, in elements of your page. Remote grids only.
| Option | Accepts | Default | Effect |
|---|---|---|---|
externalSummary | object of key: selector or Element | {} | After every load, each element's text becomes the summary value with the same key. A key missing from summary empties its element; a response without summary leaves them as they are. |
<p>Revenue: <span id="revenue"></span> from <span id="order-count"></span> orders</p>
vGrid({
el: '#orders',
dataSource: '/api/orders',
rowsPerPage: 25,
externalSummary: { revenue: '#revenue', count: '#order-count' },
columns: [{ name: 'number', label: 'Order' }, { name: 'total', label: 'Total' }]
});
Your server adds a summary array to the response:
{
"data": [ { "number": "A-1001", "total": 120 } ],
"total": 1834,
"page": 1,
"limit": 25,
"summary": [
{ "key": "revenue", "value": "$48,210.00" },
{ "key": "count", "value": "1834" }
]
}
An element that isn't on the page logs an error and is skipped.
Language
Every request a remote grid makes tells your server which language the reader uses, so the server can answer in it: translated values, local dates and numbers, names in the right script. The grid sends it as lang, always, on every request.
| Option | Accepts | Default | Effect |
|---|---|---|---|
locale | a language tag, such as 'en', 'de-AT' or 'pt-BR' | found on the page | The language the grid sends to the server as lang. |
locale: 'de-AT',
Where the language comes from
The grid takes the first of these that holds a valid language tag:
| # | Source | Example |
|---|---|---|
| 1 | locale in the grid's options | vGrid({ locale: 'de-AT' }) |
| 2 | The nearest lang attribute: on the grid's element or any element around it, up to <html> | <section lang="es">, <html lang="de-AT"> |
| 3 | The reader's browser languages, in the order the reader set them | fr-FR |
| 4 | None of the above | lang=, sent empty |
Tags are sent in their standard spelling: pt-br is sent as pt-BR. A locale that isn't a language tag logs an error and the grid looks at the page and browser instead. The language is read once, when the grid is created; changing the page's lang later does not change it.
Because the nearest lang wins over <html lang>, one page can hold grids in different languages:
<html lang="en">
…
<div id="orders"></div> <!-- sends lang=en -->
<div id="bestellungen" lang="de"></div> <!-- sends lang=de -->
What the server receives
| Request | Where lang is |
|---|---|
Rows, GET | query string: /api/orders?page=1&lang=de-AT |
Rows, POST | JSON body: { "page": 1, "lang": "de-AT" } |
| Server download | the same as rows |
| HTML and text panels | query string, whatever the panel's method: /api/order-notes?id=42&lang=de-AT |
| Nested grid panels | query string or body, as for rows; a nested grid always uses its parent grid's language |
The grid's lang replaces any other one: a lang already written in dataSource or in a panel's dataSource, and the page's own ?lang= when inheritQueryString is on. For the same reason, don't name a column or an externalFilters key lang: its filter value would be replaced.
Read it on the server like any other parameter, and fall back to your own default when it is empty or a language you don't offer:
$lang = in_array($request->input('lang'), ['en', 'de', 'fr'], true) ? $request->input('lang') : 'en';
Local grids, with data and no dataSource, make no requests and send nothing. The grid's own buttons and messages are in English.
See it in the Products example: the page is <html lang="en">, so in the Network tab every request to /api/products, and every panel it opens, carries lang=en.
Columns
columns is an array with one object per column, in display order. Each name is a key of your row objects.
vGrid({
el: '#orders',
dataSource: '/api/orders',
rowsPerPage: 25,
columns: [
{ name: 'number', label: 'Order', width: 120 },
{ name: 'status', label: 'Status', type: 'enum', options: { paid: 'Paid', due: 'Due' } },
{ name: 'customer', label: 'Customer', type: 'link' }, // href from row.link
{ name: 'notes', label: 'Notes', type: 'html', sort: false, filter: false },
{ name: 'internal_ref', label: 'Ref', active: false } // hidden at start
]
});
Identity and display
| Key | Accepts | Default | Effect |
|---|---|---|---|
name | string | required | The row key shown in this column; also its sort value and filter parameter. Reserved names stop the grid. |
label | string | name | Header text, filter placeholder, Settings list, external sort list, CSV and print heading. |
type | 'html' | 'enum' | 'link' | plain text | html: the value is inserted as HTML; sorting, filtering and CSV use its text. enum: shows options[value]. link: a link to the address in the href field when it is a / path, http: or https: address, otherwise plain text. |
href | row field name | 'link' | Which row field holds the address for type: 'link', so a row can have several link columns. |
options | object of value: text | none | The lookup for type: 'enum'. Local sorting and filtering use the shown text. |
render | (value, row) => text | Node | undefined | none | Decides what the cell shows. Text is inserted as text; a DOM node is inserted as is; undefined falls back to the normal display for that row. Local sorting and filtering use the raw value; CSV and print use the rendered text. |
width | number, '120', '120px', or any <th width> value | auto | Pixel values set the starting width; other values (such as '20%') size the column until the first layout. |
active | true | false | true | false hides the column at start; Settings can show it. At least one column must stay visible. Saved Settings win. |
isRowKey | true | false | false | The row-number cell shows this column's value. One column at most; two stop the grid. |
Per-column sorting and filtering
| Key | Accepts | Default | Effect |
|---|---|---|---|
sort | true | false | true | false makes the header unclickable and leaves the column out of the external sort list. |
filter | true | false | true | false leaves its filter cell empty. |
filterType | 'select' | text box | A drop-down built from filterOptions. |
filterOptions | string "value:Text;value:Text" | '' | The drop-down's choices. An item without : uses the same value and text; an empty value (":All") means no filter and enables the × button. |
filterClear | true | false | true | false removes this filter's × button. |
filterModes | object | none | Exact server queries for this filter; see Filter modes. |
{ name: 'status', label: 'Status', filterType: 'select', filterOptions: ':All;paid:Paid;due:Due' }
Headings sit in tr.vgrid-header, and each filter control carries vgrid-filter-<name>; every class is listed in Element class names.
.vgrid-header th { /* every column heading */ }
.vgrid-filter-status { /* the Status filter */ }
See them in the Products example: Price has filter modes; open Settings and show Condition for a drop-down filter.
Row numbers
A leading column that numbers the rows.
| Option | Accepts | Default | Effect |
|---|---|---|---|
rowNumbers | true | false | true | Shows the number column. |
rowNumbersType | 'sequential' | field name | 'sequential' | sequential counts across pages: page 2 of 25 starts at 26. A field name shows that row field instead. |
rowNumbersType: 'id', // shows row.id
A column with isRowKey: true overrides both. Print always has a number column, using these values. The number cell is the first cell of each row unless a subgrid toggle comes first:
.vgrid-row > td:first-child { /* the row numbers */ }
The Ajax example numbers across pages; the Products example turns them off.
Sorting
Click a heading, or focus it and press Enter or Space, to sort ascending, again for descending; the heading shows ▲ or ▼. Hold Shift while activating another heading to add a secondary sort; each sorted heading shows its priority number. Every sort returns to page 1. A remote grid sends sort and dir, comma-separated lists when there is more than one, and your server sorts; a local grid compares numbers when both values read as numbers, otherwise text, on the shown text.
| Option | Accepts | Default | Effect |
|---|---|---|---|
sortBy | { column, order }, or an array of them | none | The sort at start. column is a column name; order is 'asc' (default) or 'desc'. An array sets a primary sort and its tie-breakers, in order. |
externalSort | { column, direction }, each a selector or Element | {} | Your page's <select> elements driving the sort. The grid fills column with "Sort by…" and every sortable visible column, and an empty direction with Ascending and Descending. Both follow header clicks. |
<select id="sort-column"></select>
<select id="sort-direction"></select>
sortBy: { column: 'total', order: 'desc' },
externalSort: { column: '#sort-column', direction: '#sort-direction' },
sortBy: [{ column: 'status' }, { column: 'total', order: 'desc' }], // status, then total within each status
A control that isn't on the page logs an error. The external sort controls show the first sort, and sort() replaces every sort with the one you pass. The sorted heading carries aria-sort:
.vgrid-header th[aria-sort="ascending"],
.vgrid-header th[aria-sort="descending"] { /* the sorted heading */ }
Try it in the Ajax example: click Code, then click it again.
Filtering
Three ways to narrow the rows: the filter row under the headings, your page's own inputs, and filter modes for exact server queries.
Filter row
One box per column. A remote grid sends each filled box as a parameter after a 300 ms pause. A local grid keeps rows whose shown text contains the typed text, ignoring case; a drop-down matches its option value or text exactly.
| Option | Accepts | Default | Effect |
|---|---|---|---|
filterRow | true | false | true | Shows the filter row. |
filterClear | true | false | true | A × button inside each filled filter; per column with columns[].filterClear. Escape also clears a text filter. |
filterRow: false, // no filter boxes
The Clear filters button (Toolbar actions) empties them all.
.vgrid-filter { /* the filter row */ }
.vgrid-filter-clear { /* the × buttons */ }
Try it in the Local example: type in Task, then press Escape.
External filters
Your page's own inputs, sent with every request as name=value when they have a value. Remote grids only.
| Option | Accepts | Default | Effect |
|---|---|---|---|
externalFilters | object of name: selector or Element | {} | The grid reloads on each input's change event. Clear filters puts them back to their starting values. |
<select id="region">
<option value="">All regions</option>
<option value="eu">Europe</option>
</select>
<input type="date" id="from">
externalFilters: { region: '#region', from_date: '#from' }, // region=eu&from_date=2026-01-01
A text input fires change on Enter or when it loses focus. Names follow the reserved-names rule; an input that isn't on the page logs an error and is skipped. The inputs are yours, so are their styles.
See the Products example: Categories, Brand and Have Sales are external filters.
Filter modes
A mode button inside a text filter switches the parameter it sends, so your server runs exact queries (equals, starts with, between) instead of a "contains" search. Remote grids, text filters only.
{
name: 'price',
label: 'Price',
filterModes: {
price_equals: { label: 'Equals', symbol: '=' },
price_above: 'Higher than',
range: { keys: ['price_from', 'price_to'], label: 'Between', symbol: '↔' }
}
}
Typing 25 in Equals sends price_equals=25; Between shows "from" and "to" boxes and sends each filled one under its key.
| Key | Accepts | Default | Effect |
|---|---|---|---|
filterModes.<param> | label, or { label, symbol, value } | none | A mode that sends <param>. |
filterModes.<param>.label | text, HTML string or Element | the parameter name | Menu text and button tooltip. |
filterModes.<param>.symbol | string | none | Button and menu symbol; without it the button shows the label's first letter. |
filterModes.<param>.value | non-empty text or number | none | Makes it a one-click mode that sends this value; see One-click modes. |
filterModes.range | { keys, label, symbol } | none | Two boxes, "from" and "to". |
filterModes.range.keys | two different parameter names | required for range | Sent for "from" and "to". |
filterModes.range.label | text, HTML string or Element | the keys, from – to | Menu text and button tooltip. |
filterModes.range.symbol | string | none | As above. |
A "Contains" mode (≈) that sends the column name comes first, unless a key equals the column name. With fewer than two modes there is no button. Switching mode keeps the typed value and reloads if it was filled; Clear filters returns every filter to its first mode.
One-click modes
A mode with a value needs no typing: choosing it reloads the grid at once and sends <param>=<value>. Use it for questions a text box can't ask, such as rows with no date.
{
name: 'due_date',
label: 'Due',
filterModes: {
due_date_empty: { label: 'No date', symbol: '∅', value: '1' },
due_date_set: { label: 'Has a date', symbol: '✓', value: '1' }
}
}
Choosing No date sends due_date_empty=1; any text typed in the filter is dropped. The filter box then shows "No date" and can't be typed in.
- Click the box, or press Enter, Space or Down arrow in it, to open the mode menu and pick another mode.
- Clear filters, the × button, or Escape in the box returns the filter to its first mode and reloads. With
filterClear: falsethere is no × and Escape does nothing.
Your server decides what the parameter means. For "no date" on a column that can hold NULL or an empty string, match both:
WHERE due_date IS NULL OR due_date = ''
Ignored with a console error: modes without a dataSource, on a drop-down filter, with an empty or reserved key, a range without two different keys, or a value that is empty or set on a range (Troubleshooting).
.vgrid-filter-mode { /* the mode button */ }
.vgrid-filter-mode-menu { /* the menu */ }
.vgrid-filter-mode-item { /* one menu entry */ }
.vgrid-filter-mode-symbol { /* the symbol inside an entry */ }
.vgrid-filter-fixed { /* the read-only box of a one-click mode */ }
Try it in the Products example: click the mode button inside the Price filter and choose Between.
Pagination
On big data, always set rowsPerPage: without it there is no paging and your server is asked for every row.
Page size
| Option | Accepts | Default | Effect |
|---|---|---|---|
rowsPerPage | positive number | 0, no paging; 10 with externalPagination or externalRowsPerPage | Rows per page, sent as limit. |
rowsPerPageOptions | array of positive numbers | automatic | The choices in "Show N rows"; rowsPerPage is always added. Automatic: 5, 10 up to 10 rows; 10, 20, 50 up to 50; otherwise 20, 40, 50, 100, counted from local data or from each remote total. Needs rowsPerPage. |
rowsPerPage: 25, // limit=25
rowsPerPageOptions: [25, 50, 100, 500],
Pagination bars
With rowsPerPage set, a bar sits above and below the table: "Showing 26–50 of 1834", the pager (« ‹ 1 2 3 … › ») and "Show N rows". On a narrow grid the bar stacks and the pager shows fewer page numbers.
| Option | Accepts | Default | Effect |
|---|---|---|---|
showPaginationControls | true | false | true | The default for the two below. |
showPagination | true | false | showPaginationControls | The count and the pager. |
showRowsPerPage | true | false | showPaginationControls | "Show N rows". |
showRowsPerPage: false, // keep the pager, drop the page-size choice
.vgrid-pagination-bar { /* each bar */ }
.vgrid-pagination-bar[data-vgrid-stacked] { /* a bar stacked on a narrow grid */ }
.vgrid-count { /* Showing X–Y of Z */ }
.vgrid-pager button[aria-current="page"] { /* the current page */ }
.vgrid-rows-per-page { /* Show N rows */ }
See the bars in the Ajax example.
External pagination
Puts the count, the pager and the page-size choice in your own elements.
| Option | Accepts | Default | Effect |
|---|---|---|---|
externalPagination | selector, Element, array or NodeList | none | Each container's content is replaced by the count and the pager. A selector takes every element it matches. |
externalRowsPerPage | selector, Element, array or NodeList | none | Your <select> elements setting the page size, kept in step with each other and the grid; a missing size is added as an option. |
<div class="pager-top"></div>
<select id="page-size"><option>25</option><option>50</option></select>
rowsPerPage: 25,
showPaginationControls: false, // no built-in bars
externalPagination: '.pager-top',
externalRowsPerPage: '#page-size',
An element that isn't on the page logs an error. See the Products example: the pagers above and below the grid are external; change "Show" in either.
Appearance
Lines, text size, shading and motion. Colours come from your page; see Styling and class names.
| Option | Accepts | Default | Effect |
|---|---|---|---|
borders | 'all' | 'horizontal' | 'rows' | 'inner' | 'none' | 'horizontal' | all: a frame and every line. horizontal: a frame and lines between rows. rows: lines above, between and below the rows, no frame. inner: lines between rows only. none: no lines. |
size | 'smallest' | 'small' | 'medium' | 'large' | 'larger' | 'largest' | 'medium' | The starting text size, from 0.75 to 1.5 times the page's. The A− and A+ buttons step through the sizes and remember the choice in this browser. |
striped | true | false | true | Shades every other row. |
hover | true | false | true | Highlights the row under the pointer. |
rowClass | row => string | none | Classes added to each row, from its data; an empty return adds none. |
easing | milliseconds | 160 | How long panels, menus and column moves animate; 0 turns animation off. |
borders: 'all',
size: 'small',
striped: false,
easing: 0,
An unknown value logs an error and the default is used. Each option sets a hook on .vgrid-wrapper (Hooks set by options):
.vgrid-wrapper[data-vgrid-borders="all"] { /* grids with every line */ }
.vgrid-wrapper[data-vgrid-size="small"] { /* grids at size small */ }
See the Products example, set to borders: 'horizontal' and size: 'small'; try A− and A+ in its toolbar.
Column resizing and reordering
Resizing
Drag the line between two headings, or two filter boxes, to resize. The columns to the right give or take the space, and none gets narrower than 40 px; when the columns to the right have no space left, the table grows instead. Always on; there is no option. The pointer turns into a resize cursor over a line.
When the columns cannot fit in the available width, the grid scrolls sideways rather than squeezing any column below 40 px; the scrolling area can be focused and scrolled with the arrow keys.
Without a pointer: focus a heading and press Alt+ArrowRight to widen it or Alt+ArrowLeft to narrow it by 10 px, or use the − and + buttons next to each column in Settings.
Reordering
Hold a heading, then drag it to a new place; a marker shows where it will land. Moving the pointer before the hold time is up cancels the drag, so a normal click still sorts.
| Option | Accepts | Default | Effect |
|---|---|---|---|
reorderColumns | true | false | true | Lets readers move columns. Escape cancels a drag. |
reorderDelay | milliseconds | 350 | How long to hold before the drag starts. |
reorderDelay: 600, // a longer hold, for touch screens
Without a pointer, the ↑ and ↓ buttons next to each column in Settings move it one place. The new order lasts until the page reloads; Settings → Save remembers it. It needs two or more visible columns.
.vgrid-column-ghost { /* the heading being dragged */ }
.vgrid-column-marker { /* the drop line */ }
[data-vgrid-drag-source] { /* the column's cells during the drag */ }
[data-vgrid-dropped] { /* the column's cells just after the drop */ }
Try both in the Ajax example: drag the line between Code and Name, then hold Name and drag it left.
Toolbar actions
A group of buttons, by default at the right of the caption row: Clear filters ✕, Download ⤓, Print ⤒, Refresh ↻, Settings ⚙, and A− / A+ for text size.
Feature switches
| Option | Accepts | Default | Effect |
|---|---|---|---|
settings | true | false | true | The Settings popup, plus Clear filters and Refresh. |
download | true | false | 'server' | 'view' | false | 'server': your server sends the full filtered result as a file. 'view': a CSV made in the browser. true: server with a dataSource, otherwise view; 'server' without a dataSource falls back to view. |
print | true | false | true | Prints the rows on screen as a plain table. |
externalSettings | selector or Element | none | Older name for actions.element. |
download: 'server',
print: false,
A server download repeats the current request (filters, sort, visible columns, page and limit) with download=true, and saves whatever file comes back. Ignore page and limit to send the full result, in any format; name the file with Content-Disposition:
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename="orders.csv"
Without a file name, the grid uses <namespace>-<date> and an extension from Content-Type. The button is disabled while the file loads; a failure shows in the grid like any broken response.
A browser CSV ('view') has the column labels as headings and each cell's shown text. A local grid saves every filtered row; a remote grid saves the page on screen. Print always takes the page on screen.
The button group
actions accepts true (the caption row), false (no buttons), a position string, or an object:
| Key | Accepts | Default | Effect |
|---|---|---|---|
position | 'caption' | 'bottom' | 'none' | 'external' | 'caption' | bottom: a row under the table. external needs element; without one the buttons stay in the caption. |
element | selector or Element | none | Your container for the buttons; its content is replaced. When set, it wins over position. |
order | array of action names | ['clear', 'download', 'print', 'refresh', 'settings', 'textSize'] | Listed actions first, the rest in default order. |
clear | action value | follows settings | Clear filters ✕. |
download | action value, 'server' or 'view' | follows download | Download ⤓; overrides the top-level download. |
print | action value | follows print | Print ⤒. |
refresh | action value | follows settings | Refresh ↻: reloads remote rows, redraws local ones. |
settings | action value | follows settings | Settings ⚙. |
textSize | true | false | object | true | A− and A+. |
actions: {
element: '#toolbar', // your container
order: ['refresh', 'download'],
download: 'server',
print: false
},
An unknown key or action name logs an error.
Action values
Each action takes true, false, a string (its symbol), or an object:
| Key | Accepts | Default | Effect |
|---|---|---|---|
active | true | false | true when the object is given | false hides the button. |
symbol | string | ✕ ⤓ ⤒ ↻ ⚙ | The button text. |
title | string | "Clear filters", "Download", "Print", "Refresh", "Settings" | The tooltip. |
html | HTML string or Element | none | The button content, inserted as HTML; wins over symbol. |
className | string | none | Classes on the button. |
mode | 'server' | 'view' | 'server' with a dataSource, else 'view' | download only. |
decrease, increase | string | A−, A+ | textSize only: the button texts. |
decreaseTitle, increaseTitle | string | "Smaller text", "Larger text" | textSize only: the tooltips. |
textSize takes active and className as well.
actions: {
refresh: { title: 'Reload orders', className: 'btn btn-sm btn-outline-secondary' },
clear: '⌫', // a symbol
textSize: { decrease: 'a', increase: 'A' }
},
Settings popup and remembered choices
Settings lists the columns with a checkbox each. Apply shows the choice until the page reloads; Save also remembers it in this browser, with the column order. At least one column stays checked. Saved choices win over columns[].active, and hidden columns are sent to the server as columns.
Next to each column, ↑ and ↓ move it one place and − and + change its width by 10 px; these act at once, and the width lasts until the page reloads.
Choices live in localStorage under vgrid:<el>:<dataSource>:columns, and the text size under …:textSize; <el> is the el selector, or the element's id. Change el or dataSource and earlier choices no longer apply. To forget them:
localStorage.removeItem('vgrid:#orders:/api/orders:columns');
.vgrid-actions { /* the button group */ }
.vgrid-settings { /* the popup */ }
.vgrid-settings-actions { /* Apply and Save */ }
.vgrid-settings-close { /* the × in the popup */ }
See the Products example: its buttons sit in the page's own toolbar. Open Settings, tick Category, press Save, then reload the page.
Subgrids: detail panels per row
Each subgrid adds a toggle column. Opening a row's toggle loads its panel from your server: one request per opened row, so detail data is fetched only when someone looks at it.
vGrid({
el: '#orders',
dataSource: '/api/orders',
rowsPerPage: 25,
columns: [{ name: 'number', label: 'Order' }, { name: 'total', label: 'Total' }],
subgrid: {
panels: [
{
name: 'items', label: 'Items', id: 'id', type: 'grid',
dataSource: '/api/order-items',
columns: [{ name: 'sku', label: 'SKU' }, { name: 'qty', label: 'Qty' }]
},
{ name: 'notes', label: 'Notes', id: 'id', type: 'html', dataSource: '/api/order-notes' }
]
}
});
For the row with id 42, Items requests /api/order-items?id=42 with the usual paging parameters and expects the usual response; Notes requests /api/order-notes?id=42 and inserts the answer as HTML.
With one panel available, the toggle (+ / -) opens it directly; with several, ⋮ opens a menu of them plus Hide. A row shows one panel at a time: opening another replaces it. Panels taller than 400 px scroll.
Subgrid group
subgrid is one object or an array of them; each adds its own toggle column.
| Key | Accepts | Default | Effect |
|---|---|---|---|
panels | array of panel objects | required | The panels this toggle offers. A group without a valid panel is dropped. |
position | 'start' | 'end' | column number | 'start' | Where the toggle column sits; numbers count cells from the left, 1 first. |
single | true | false | false | true keeps one panel open in the whole grid. |
open | true | panel name | none | The entry focused first in the menu (true: the first). It opens nothing by itself. |
el | HTML string | + / -, ⋮ for a menu | Your own toggle markup; its first element is used and made a keyboard button. |
method | 'GET' | 'POST' | 'GET' | The default for every panel. |
headers | object | the grid's headers | The default for every panel. |
rowsPerPage | positive number | 10 | The default for every grid panel. |
externalFilters | object of name: selector or Element | none | The default for every panel. |
subgrid: [
{ position: 'start', panels: [/* … */] },
{ position: 'end', single: true, el: '<button type="button">Details</button>', panels: [/* … */] }
],
Panels
| Key | Accepts | Default | Effect |
|---|---|---|---|
name | string | required | The panel's identity; used by open. |
label | string | name | Menu text, toggle tooltip and messages. |
id | row field name | required | The row field whose value is sent. Rows without a value show no toggle for this panel. |
dataSource | URL | required | Requested as dataSource?id=<value>. |
idInPath | true | false | false | Requests dataSource/<value> instead. |
type | 'grid' | 'html' | plain text | grid: a nested grid. html: the answer inserted as HTML. Omitted: the answer shown as text. |
caption | HTML string | false | none | A heading above the content; the × close button is always there. |
method | 'GET' | 'POST' | the group's, else 'GET' | |
headers | object | the group's, else the grid's | |
externalFilters | object of name: selector or Element | the group's | Your page's inputs, added to the panel's request when it opens. |
A panel without name, id or dataSource is dropped with a console error. While loading, a panel shows "Loading…"; an empty answer shows "Nothing to show here for Notes."; a failure shows "Notes could not be loaded: the server answered 500."
Nested grid panels
A type: 'grid' panel is a remote grid on the panel URL: its server receives id plus page, limit, sort, dir and filters. These keys apply to grid panels only:
| Key | Default |
|---|---|
columns | [], with every column key |
rowsPerPage | the group's, else 10 |
rowNumbers, rowNumbersType, borders, striped, hover, easing, reorderColumns, reorderDelay | the parent grid's value |
size | 'medium', not inherited |
Nested grids have no Settings or toolbar, and share the parent's namespace.
Query string inheritance
| Option | Accepts | Default | Effect |
|---|---|---|---|
inheritQueryString | true | false | false | Adds the page's own query string to every panel request; a parameter already on the panel URL wins. The main grid's requests are unchanged. |
inheritQueryString: true, // on /orders?tenant=7, panels request /api/order-items?tenant=7&id=42
.vgrid-toggle-subgrid { /* every toggle */ }
.vgrid-toggle-subgrid-2 { /* the second group's toggles */ }
.vgrid-panel-menu { /* the panel menu */ }
.vgrid-subgrid-body { /* an open panel */ }
.vgrid-subgrid-caption { /* its heading and × */ }
.vgrid-panel-error { /* a panel that failed */ }
See both kinds in the Products example: the + at the start of a row opens Summary, an HTML panel requested as /api/products/summary/<id> (idInPath); the ↗ at the end opens Sales, a nested grid.
Row interaction
Ways for readers to act on a row or copy from it. All are off unless you turn them on.
Row click and double-click
| Option | Accepts | Default | Effect |
|---|---|---|---|
onRowClick | function | none | Called with { row, event } when a row is clicked, or Enter or Space is pressed on a focused row. |
onRowDblClick | function | none | Called with { row, event } on a double-click, or Shift+Enter on a focused row. |
onRowClick: ({ row }) => location.assign('/orders/' + row.id),
Clicks on a button, link or form control inside the row do not count. With cellSelection on, the keys work from a focused cell instead of the row.
Row actions
Buttons at the end of every row, in a column headed Actions.
| Option | Accepts | Default | Effect |
|---|---|---|---|
rowActions | array of actions, or a function of the row returning one | false | Adds the Actions column. A function decides per row which buttons appear. |
| Action key | Accepts | Default | Effect |
|---|---|---|---|
label | text | 'Action' | The button's text. |
name | text | the label | Identifies the action: it is set as data-vgrid-row-action on the button and passed to onClick. |
onClick | function | required | Called with { row, event, grid, name }; grid is the instance. |
ariaLabel | text | the label | The accessible name, for icon-like labels. |
disabled | true | false | false | Renders the button disabled. |
rowActions: row => [
{ name: 'open', label: 'Open', onClick: ({ row }) => location.assign('/orders/' + row.id) },
{ name: 'void', label: 'Void', disabled: row.status === 'void',
onClick: ({ row, grid }) => fetch('/api/orders/' + row.id + '/void', { method: 'POST' }).then(() => grid.reload()) },
],
.vgrid-row-actions { /* the Actions cell */ }
[data-vgrid-row-action="void"] { /* one action's button */ }
Row reorder
A column after the data columns with ↑ and ↓ buttons that move a row one place.
| Option | Accepts | Default | Effect |
|---|---|---|---|
rowReorder | true | false | false | Adds the Row order column. |
onRowReorder | function | none | Called with { row, from, to, rows } after a move; from and to count from 0 across the whole data set, rows is the current page. Required on a remote grid, where your server stores the order. |
rowReorder: true,
onRowReorder: ({ row, to }) => fetch('/api/queue/' + row.id + '/position', { method: 'PUT', body: String(to) }),
A local grid moves the row in its own data. While a sort or a filter is active the buttons are disabled.
.vgrid-row-reorder { /* the Row order cell */ }
[data-vgrid-row-reorder="up"], [data-vgrid-row-reorder="down"] { /* the two buttons */ }
Cell selection and copy
| Option | Accepts | Default | Effect |
|---|---|---|---|
cellSelection | true | false | false | Cells become focusable: arrow keys move between them, Shift with an arrow key extends the selected range, and Ctrl+C or Command+C copies the range as tab-separated text. Pasting is not supported. |
cellSelection: true,
Clicking a cell selects it; clicks on buttons and links inside a cell are left alone. Only visible columns are copied.
.vgrid [aria-selected="true"] { /* a selected cell */ }
Events
Functions the grid calls as things happen. Each gets one object; the return value is ignored.
| Option | Called when | Argument |
|---|---|---|
onReady | rows are on screen: after every local render, after every remote load, or when a remote load fails | { total, rows }: matching rows and rows on screen; or { error } |
onSort | the sort changes: a heading, the external sort, or sort() | { column, order } for the first sort; with a secondary sort also sorts: [ { column, order } ] in priority order |
onFilter | a filter changes or Clear filters runs. Local grids only; on a remote grid use onReady | { filters: [ { column, value } ] } |
onPage | a page is shown, when rowsPerPage is set | { page, rowsPerPage, total } |
onRowClick, onRowDblClick | a row is clicked or double-clicked, or Enter / Space / Shift+Enter on a focused row. See Row click and double-click | { row, event } |
onRowReorder | a row's ↑ or ↓ button moves it. See Row reorder | { row, from, to, rows } |
vGrid({
el: '#orders',
dataSource: '/api/orders',
rowsPerPage: 25,
columns: [{ name: 'number', label: 'Order' }, { name: 'total', label: 'Total' }],
onReady: ({ total, error }) => {
document.querySelector('#order-count').textContent = error ? 'unavailable' : total;
},
onPage: ({ page }) => history.replaceState(null, '', '?page=' + page)
});
Instance API
vGrid() returns an object for later changes. Keep it.
| Member | Effect |
|---|---|
element | The grid's <table>. |
config | The object you passed in. |
reload(rows?) | Back to page 1: a remote grid refetches; a local grid redraws, with new rows if you pass an array. |
setPage(n) | Shows page n, 1 or more. A local grid stops at its last page; on a remote grid your server's page answer decides. |
sort(column, order = 'asc') | Sorts by a visible column; another name logs a warning. |
destroy() | Removes listeners, requests, panels and the grid's markup. A <table> el stays, empty. |
const grid = vGrid({ /* … */ });
document.querySelector('#refresh').addEventListener('click', () => grid?.reload());
grid?.sort('total', 'desc').setPage(3); // reload, setPage and sort chain
grid?.destroy(); // when the grid leaves the page
vGrid() returns null, with a console error, when el finds nothing, a column or external filter uses a reserved name, two columns set isRowKey, or every column is hidden. Hence the ?. above.
Styling and class names
vgrid.css handles layout only; colours and fonts come from your page. Restyle the grid with the hooks, variables and classes below.
#orders {
--vgrid-border-color: #c9d1d9;
--vgrid-hover-color: #fff8e1;
}
#orders .vgrid-caption { /* the Orders grid's caption only */ }
Hooks set by options
| Option | Hook |
|---|---|
borders | data-vgrid-borders="all | horizontal | rows | inner | none" on .vgrid-wrapper |
size | data-vgrid-size="smallest | small | medium | large | larger | largest" on .vgrid-wrapper |
striped | data-vgrid-striped on .vgrid-wrapper; shaded rows carry data-vgrid-stripe |
hover | data-vgrid-hover on .vgrid-wrapper |
reorderColumns | data-vgrid-reorder on .vgrid-wrapper |
onRowClick, onRowDblClick | data-vgrid-clickable on each tr.vgrid-row |
cellSelection | aria-selected="true" on selected cells |
easing | --vgrid-easing: <easing>ms on .vgrid-wrapper |
namespace | <namespace>-<part> next to every vgrid-<part> class |
CSS variables
Set them in your CSS, on the grid's container or any ancestor; they are not vGrid({ … }) options.
| Variable | Default | Affects |
|---|---|---|
--vgrid-border-color | #dee2e6 | Lines, frames and popup borders |
--vgrid-border-width | 1px | Every line |
--vgrid-border-h | --vgrid-border-width | Lines between rows |
--vgrid-border-v | --vgrid-border-width; 0 under horizontal, rows, inner | Lines between columns |
--vgrid-border-radius | .25rem | Frame and popup corners |
--vgrid-font-size | set by size, .75em to 1.5em | The grid's text; override with a rule more specific than .vgrid-wrapper[data-vgrid-size] |
--vgrid-hover-color | rgba(128, 128, 128, .18) | The row under the pointer |
--vgrid-stripe-color | rgba(128, 128, 128, .07) | Shaded rows |
--vgrid-popup-background | Canvas | Settings, menus and the loading badge |
--vgrid-popup-shadow | 0 .4em 1.4em rgba(0, 0, 0, .18) | Settings, menus and the loading badge |
--vgrid-drag-shadow | a soft two-layer shadow | The heading being dragged |
--vgrid-drop-color | the system accent colour, faded | The moved column's brief highlight after the drop |
--vgrid-scrollbar-color | the text colour, faded | Scrollbars of Settings, menus and panels |
--vgrid-subgrid-max-height | 400px | The height at which a panel scrolls |
Element class names
Every class below also comes as <namespace>-<part>, except the five fixed ones at the end of the table.
| Class | Element |
|---|---|
vgrid-caption | The caption row |
vgrid-actions | The button group in the caption or bottom row |
vgrid-actions-row | The bottom row holding the buttons |
vgrid-settings, vgrid-settings-close, vgrid-settings-title, vgrid-settings-list, vgrid-settings-actions | The Settings popup, its ×, its heading, its scrolling column list, its Apply and Save |
vgrid-header | The heading row |
vgrid-filter | The filter row |
vgrid-filter-<name> | One column's filter box or drop-down |
vgrid-filter-clear | A filter's × button |
vgrid-filter-moded | A filter with modes, button and boxes |
vgrid-filter-mode, vgrid-filter-mode-<name> | The mode button |
vgrid-filter-mode-menu, vgrid-filter-mode-item, vgrid-filter-mode-symbol | The mode menu, an entry, an entry's symbol |
vgrid-filter-fixed | The read-only box of a one-click mode |
vgrid-row | A data row; with a namespace and row.id, also <namespace>-row-<id> |
vgrid-row-actions, vgrid-row-reorder | A row's Actions cell, its Row order cell; buttons carry data-vgrid-row-action="<name>" and data-vgrid-row-reorder="up | down" |
vgrid-empty | The "No records to show." row |
vgrid-loading-space | Room for the loading badge while there are no rows |
vgrid-pagination, vgrid-pagination-bar | A pagination row, its bar |
vgrid-count, vgrid-pager, vgrid-pager-ellipsis, vgrid-rows-per-page | "Showing X–Y of Z", the pager, its …, "Show N rows" |
vgrid-toggle-column | The <col> of a subgrid toggle column |
vgrid-toggle-subgrid, vgrid-toggle-subgrid-<n> | A toggle; n counts the groups from 1 |
vgrid-panel-menu, vgrid-panel-menu-<n>, vgrid-panel-menu-item | The panel menu and its entries |
vgrid-subgrid-cell, vgrid-subgrid-slide, vgrid-subgrid-clip, vgrid-subgrid-body | An open panel, outside in |
vgrid-subgrid-caption, vgrid-subgrid-title, vgrid-subgrid-close | A panel's heading bar, its text, its × |
vgrid-panel-loading, vgrid-panel-empty, vgrid-panel-error, vgrid-panel-text | A panel's messages, and a plain-text answer |
vgrid-column-ghost, vgrid-column-marker | The dragged heading, the drop line |
vgrid | The <table>; fixed |
vgrid-wrapper | The element around the table; fixed |
vgrid-loading-badge, vgrid-loading-error | The loading badge, and the badge showing an error; fixed |
vgrid-spinner, vgrid-loading-text | The badge's spinner and text; fixed |
Keyboard and accessibility
What works without a mouse, and what assistive technology hears.
- Menus (filter modes, subgrid panels): ArrowUp and ArrowDown move, Home and End jump, Escape closes; the filter-mode menu returns focus to its button.
- Subgrid toggles: Enter or Space; custom
subgrid.elmarkup is made focusable withrole="button". - Filters: Escape clears a text filter. Column drag: Escape cancels.
- Sorting: Enter or Space on a heading; Shift with it adds a secondary sort.
- Rows and cells: with
onRowClick, Enter, Space and Shift+Enter on a row; withcellSelection, arrow keys move between cells and Ctrl+C or Command+C copies. - Announced:
aria-sorton the sorted heading,aria-expandedon toggles,aria-currenton the current page, labelled pager, clear and close buttons, and loading and errors through a live region. - Columns: Alt+ArrowLeft or Alt+ArrowRight on a focused heading resizes it; the ↑ ↓ − + buttons in Settings move and resize any column.
- With reduced motion set in the operating system, the grid's transitions shorten;
easing: 0turns them off.
externalSort: { column: '#sort-column', direction: '#sort-direction' }, // keyboard sorting
Security
What the grid inserts as HTML, and what it guards, so you know what your server must clean.
- Inserted as HTML, so escape any user data in it:
caption,type: 'html'cells,type: 'html'panel answers, panelcaption,subgrid.el,filterModeslabels, and actionhtml. Everything else is inserted as text. - Links:
type: 'link'links only to paths that start with a single/, and tohttp:andhttps:addresses; anything else, such asjavascript:or//other.host, shows as text. A node returned byrenderis yours: what you put in it is inserted as is. - CSV: cells starting with
=,+,@, a tab, or a-that isn't a number get a leading', so spreadsheets don't run them as formulas. - Headers:
headersgo to every grid and panel request, other origins included; the CSRF token goes to the same origin only.
{ "data": [ { "notes": "<b>Paid</b> by <script>" } ] }
Above: a type: 'html' cell keeps your bold tag and shows the user's text as typed, because the server escaped it.
Troubleshooting
Every message the grid writes to the browser console, what causes it, and the fix. Messages marked stops mean vGrid() returned null.
| Message | Cause and fix |
|---|---|
| Setup | |
vGrid: "el" must identify an element. stops | The selector found nothing. Run vGrid() after the element exists. Mounting |
vGrid: autoDestroy must be true or false. | Use a boolean. Mounting |
vGrid: borders must be one of …, size must be one of …, striped / hover must be true or false., easing must be a duration … | The default is used instead. Appearance |
vGrid: locale must be a language tag such as "en" or "de-AT". | The grid uses the page's or the browser's language instead. Use a tag like 'de' or 'de-AT'. Language |
| Data and server | |
vGrid fetch error: with the reason | A request failed; the reason also shows in the grid. Broken responses |
vGrid: external summary target for "…" was not found. | Fix the selector. Server totals |
vGrid: reload() expects an array of rows. | Pass an array, or nothing. Instance API |
| Columns | |
vGrid: column name(s) … are reserved for remote API controls. stops | Rename the column or external filter. Reserved names |
vGrid: only one column can use isRowKey: true. stops | Keep it on one column. Columns |
vGrid: render on column "…" must be a function., … must return text or a DOM node., … failed: … | The cell shows the raw value instead. Columns |
vGrid: href on column "…" must be a row field name. | row.link is used. Columns |
vGrid: rowClass must be a function., rowClass failed: … | No class is added. Appearance |
vGrid: at least one column must stay active. stops | Leave one column without active: false. Columns |
vGrid: column "…" not found (a warning) | sort() named a hidden or unknown column. Instance API |
| Sorting and filtering | |
vGrid: external sort column / direction control was not found. | Fix the selector. Sorting |
vGrid: external filter for "…" was not found. | Fix the selector; that filter is skipped. External filters |
vGrid: filterModes on column "…" needs a dataSource. | Modes need a remote grid. Filter modes |
vGrid: filterModes on column "…" works with text filters only. | Remove filterType: 'select' or the modes. Filter modes |
vGrid: filterModes on column "…" must be an object of parameter: label pairs. | Pass an object. Filter modes |
vGrid: filterModes range on column "…" needs keys: an array of two different parameter names. | Give range.keys two different names. Filter modes |
vGrid: filterModes on column "…" has an empty parameter name., … uses the reserved name(s) …. | That mode is dropped; rename it. Filter modes |
vGrid: filterModes key "…" on column "…" needs a non-empty value and cannot be a range. | That mode is dropped; give value some text, or remove it from range. One-click modes |
| Pagination | |
vGrid: rowsPerPageOptions must be a non-empty array of positive numbers., … has no effect without a positive rowsPerPage. | Fix the array, or set rowsPerPage. Page size |
vGrid: setPage() expects a page number of 1 or greater. | Pass a whole number from 1. Instance API |
vGrid: external pagination control was not found., external rows-per-page control was not found. | Fix the selector. External pagination |
| Toolbar actions | |
vGrid: actions must be true, false, a position name, or an object., actions.position must be one of … | Use an accepted value. The button group |
vGrid: actions.… is not a known action., actions.order has no action named …., actions.… must be true, false, a symbol, or an object. | Check the spelling. Action values |
vGrid: actions.element was not found., external settings control was not found., actions.position 'external' needs actions.element. | Fix or add the container; the buttons fall back to the caption. The button group |
vGrid: download must be true, false, or one of server, view., download: 'server' needs a dataSource. | Use an accepted value; without a dataSource the download is a browser CSV. Feature switches |
vGrid: print must be true or false. | Use a boolean. Feature switches |
vGrid download error:, vGrid print error: | The server download or the print view failed; the reason shows in the grid. Feature switches |
vGrid: reorderDelay must be a number of milliseconds, 0 or more. | The default 350 is used. Reordering |
| Row interaction | |
vGrid: rowActions must be an array, a function, or false., rowActions[].name must be a string. | No Actions column; or that action is named after its label. Row actions |
vGrid: rowReorder must be true or false., cellSelection must be true or false. | The feature stays off. Row interaction |
| Subgrids | |
vGrid: subgrid panel "…" needs an id naming the column its value comes from., … needs a dataSource. | That panel is dropped; add the key. Panels |
vGrid: subgrid position must be "start", "end", or the column number counted from the left. | The toggle goes to the start. Subgrid group |
vGrid: subgrid open: "…" does not match any panel name. | Use a panel's name. Subgrid group |
vGrid: subgrid panel "…" external filter for "…" was not found. | Fix the selector. Panels |
vGrid panel error: | A panel request failed; the panel shows the reason. Panels |
Errors shown in the grid itself are listed in Slow, empty and broken responses.
Recipes
Common questions, each with the snippet that answers it.
Filter by a date range
{
name: 'shipped', label: 'Shipped',
filterModes: { range: { keys: ['shipped_from', 'shipped_to'], label: 'Between', symbol: '↔' } }
}
Your server reads shipped_from and shipped_to. More in Filter modes.
Export the full filtered result, not just this page
download: 'server', // your server receives download=true with the filters and sort
When download=true arrives, ignore page and limit and return a file in any format. More in Feature switches and The request.
Show totals outside the grid
externalSummary: { revenue: '#revenue' }, // response: "summary": [ { "key": "revenue", "value": "…" } ]
More in Server totals.
Send an auth token
headers: { Authorization: 'Bearer ' + token }, // subgrid panels get it too
More in Remote data.
Drive the grid from your own search form
<input type="search" id="q" placeholder="Search orders">
<select id="status"><option value="">Any status</option><option value="paid">Paid</option></select>
externalFilters: { q: '#q', status: '#status' }, // the search box sends on Enter
More in External filters.
Show detail rows for each record
subgrid: {
panels: [{
name: 'items', label: 'Items', id: 'id', type: 'grid',
dataSource: '/api/order-items', // requested as ?id=<the row's id>
columns: [{ name: 'sku', label: 'SKU' }, { name: 'qty', label: 'Qty' }]
}]
},
More in Subgrids.
Remember each reader's columns
settings: true, // the default: Settings → Save remembers columns in this browser
Choices are kept per browser, under the grid's el and dataSource; keep both stable. More in Settings popup.
Put several grids on one page
vGrid({ el: '#orders', dataSource: '/api/orders', columns: [/* … */] });
vGrid({ el: '#returns', dataSource: '/api/returns', columns: [/* … */] });
Each grid takes its classes from its container's id; set namespace to choose another. More in Mounting and identity.
Available Options
Every option vGrid({ … }) accepts, grouped by what it controls. Select a name for a short description and an example.
Mounting
Data and server
Columns
Column keys
activefilterfilterClearfilterModesfilterOptionsfilterTypehrefisRowKeylabelnameoptionsrendersorttypewidth
Row numbers
Sorting
Filtering
Pagination
externalPaginationexternalRowsPerPagerowsPerPagerowsPerPageOptionsshowPaginationshowPaginationControlsshowRowsPerPage
Appearance
Column reordering
Toolbar actions
Subgrids
Row interaction
Events
All options, A to Z
actions
Places and configures the toolbar buttons. Keys: position, element, order, clear, download, print, refresh, settings, textSize. Default: 'caption'.
actions: { position: 'bottom' },
active column key
Hides the column at start when false; Settings can show it. Default: true.
{ name: 'ref', active: false }
autoDestroy
Destroys the grid once its container has left the page. Default: false.
autoDestroy: true,
borders
Chooses which lines the grid draws. Default: 'horizontal'.
borders: 'all',
caption
Adds a caption row at the top of the table, inserted as HTML. Default: none.
caption: 'Orders',
cellSelection
Makes cells focusable, with arrow-key movement, Shift-extended ranges and copy as tab-separated text. Default: false.
cellSelection: true,
More in Cell selection and copy
columns
Lists the columns, one object per column, in display order; each key has its own entry under Column keys. Default: [].
columns: [{ name: 'number', label: 'Order' }],
data
Gives the grid its rows in the browser. Default: [].
data: [{ number: 'A-1001', total: 120 }],
dataSource
Makes the grid remote: your server pages, sorts and filters. Default: none.
dataSource: '/api/orders',
download
Adds a Download button, served by your server or made in the browser. Default: false.
download: 'server',
easing
Sets how long the grid animates, in milliseconds. Default: 160.
easing: 0, // no animation
el
Names the element the grid goes into. Default: required.
el: '#orders',
externalFilters
Sends your page's own inputs with every request. Default: {}.
externalFilters: { region: '#region' },
externalPagination
Puts the count and the pager in your own elements. Default: none.
externalPagination: '.pager',
externalRowsPerPage
Lets your own <select> set the page size. Default: none.
externalRowsPerPage: '#page-size',
externalSettings
The older name for actions.element. Default: none.
externalSettings: '#toolbar',
externalSort
Lets your own <select> elements drive the sort. Keys: column, direction. Default: {}.
externalSort: { column: '#sort-column', direction: '#sort-direction' },
externalSummary
Fills your page's elements with totals your server sends. Default: {}.
externalSummary: { revenue: '#revenue' },
filter column key
Removes the column's filter when false. Default: true.
{ name: 'notes', filter: false }
More in Per-column sorting and filtering
filterClear
Adds a × button inside filled filters. Default: true.
filterClear: false,
filterClear column key
Removes this column's × button when false. Default: true.
{ name: 'status', filterClear: false }
More in Per-column sorting and filtering
filterModes column key
Adds a mode button that switches the parameter the filter sends. Keys: one per parameter, with label, symbol and value; range with keys, label and symbol. Default: none.
{ name: 'price', filterModes: { price_equals: 'Equals' } }
filterOptions column key
Lists the drop-down's choices as "value:Text;value:Text". Default: ''.
{ name: 'status', filterType: 'select', filterOptions: ':All;paid:Paid' }
More in Per-column sorting and filtering
filterRow
Shows the row of filter boxes under the headings. Default: true.
filterRow: false,
filterType column key
Turns the filter into a drop-down. Default: text box.
{ name: 'status', filterType: 'select' }
More in Per-column sorting and filtering
headers
Adds headers to every request, subgrid panels included. Default: {}.
headers: { Authorization: 'Bearer ' + token },
hover
Highlights the row under the pointer. Default: true.
hover: false,
href column key
Names the row field holding the address of a type: 'link' column. Default: 'link'.
{ name: 'customer', type: 'link', href: 'customerUrl' }
inheritQueryString
Adds the page's query string to every panel request. Default: false.
inheritQueryString: true,
More in Query string inheritance
isRowKey column key
Shows this column's value in the row-number cell. Default: false.
{ name: 'number', isRowKey: true }
label column key
Sets the column heading. Default: name.
{ name: 'number', label: 'Order' }
locale
Sets the language the grid sends to the server as lang. Default: the nearest lang on the page, then the browser's language.
locale: 'de-AT',
method
Sends requests as GET, or as a JSON body with POST. Default: 'GET'.
method: 'POST',
name column key
Names the row key the column shows. Default: required.
{ name: 'number' }
namespace
Names the grid's extra classes and its fallback file names. Default: el's id.
namespace: 'orders-grid',
onFilter
Called when a filter changes on a local grid. Default: none.
onFilter: ({ filters }) => console.log(filters),
onPage
Called when a page is shown. Default: none.
onPage: ({ page, total }) => console.log(page, total),
onReady
Called when rows are on screen, or when a remote load fails. Default: none.
onReady: ({ total, error }) => console.log(total, error),
onRowClick
Called with { row, event } when a row is clicked, or Enter or Space is pressed on it. Default: none.
onRowClick: ({ row }) => location.assign('/orders/' + row.id),
More in Row click and double-click
onRowDblClick
Called with { row, event } on a double-click, or Shift+Enter on a focused row. Default: none.
onRowDblClick: ({ row }) => openEditor(row),
More in Row click and double-click
onRowReorder
Called with { row, from, to, rows } after a row's ↑ or ↓ button moves it; required for a remote grid with rowReorder. Default: none.
onRowReorder: ({ row, to }) => saveOrder(row.id, to),
onSort
Called when the sort changes; with a secondary sort the object also has sorts. Default: none.
onSort: ({ column, order }) => console.log(column, order),
options column key
Maps values to shown text for type: 'enum'. Default: none.
{ name: 'status', type: 'enum', options: { paid: 'Paid' } }
print
Adds a Print button for the rows on screen. Default: true.
print: false,
render column key
Returns what the cell shows: text, a DOM node, or undefined for the normal display. Default: none.
{ name: 'price', render: value => '$' + Number(value).toFixed(2) }
reorderColumns
Lets readers move columns by holding and dragging a heading. Default: true.
reorderColumns: false,
reorderDelay
Sets how long to hold a heading before it moves, in milliseconds. Default: 350.
reorderDelay: 600,
rowActions
Buttons at the end of every row. An array of { name, label, onClick, ariaLabel, disabled }, or a function of the row returning one. Default: false.
rowActions: [{ name: 'open', label: 'Open', onClick: ({ row }) => location.assign('/orders/' + row.id) }],
rowReorder
Adds a column of ↑ and ↓ buttons that move a row one place. Default: false.
rowReorder: true,
rowClass
Returns classes to add to a row, from its data. Default: none.
rowClass: row => row.status === 'overdue' ? 'table-danger' : '',
rowNumbers
Shows a leading column of row numbers. Default: true.
rowNumbers: false,
rowNumbersType
Counts the rows, or shows a row field, in the number column. Default: 'sequential'.
rowNumbersType: 'id',
rowsPerPage
Sets how many rows each page shows; the server receives it as limit. Default: 0, no paging.
rowsPerPage: 25,
rowsPerPageOptions
Sets the choices in "Show N rows". Default: automatic.
rowsPerPageOptions: [25, 50, 100],
settings
Adds the Settings popup, Clear filters and Refresh. Default: true.
settings: false,
showPagination
Shows the count and the pager. Default: showPaginationControls.
showPagination: false,
showPaginationControls
Shows or hides both parts of the pagination bars. Default: true.
showPaginationControls: false,
showRowsPerPage
Shows "Show N rows". Default: showPaginationControls.
showRowsPerPage: false,
size
Sets the starting text size. Default: 'medium'.
size: 'small',
sort column key
Makes the heading unsortable when false. Default: true.
{ name: 'notes', sort: false }
More in Per-column sorting and filtering
sortBy
Sorts the grid at start. Keys: column, order; an array adds tie-breakers in order. Default: none.
sortBy: { column: 'total', order: 'desc' },
sortBy: [{ column: 'status' }, { column: 'total', order: 'desc' }],
striped
Shades every other row. Default: true.
striped: false,
subgrid
Adds detail panels that open per row. Keys: panels, position, single, open, el, method, headers, rowsPerPage, externalFilters; panels take name, label, id, dataSource, idInPath, type, caption, method, headers, externalFilters, and grid keys for grid panels. Default: none.
subgrid: { panels: [{ name: 'items', id: 'id', dataSource: '/api/order-items' }] },
type column key
Shows the value as HTML, as looked-up text, or as a link. Default: plain text.
{ name: 'notes', type: 'html' }
width column key
Sets the column's starting width. Default: auto.
{ name: 'number', width: 120 }