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.

FileThis site
vgrid.min.jsDownload
vgrid.min.cssDownload
vgrid.js, readableDownload
vgrid.css, readableDownload

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:

BrowserMinimum
Chrome, Edge114
Firefox125
Safari, and every browser on iOS / iPadOS17
Opera100
Samsung Internet23

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
ImportGives you
vgridThe vGrid function, as ES module and CommonJS
vgrid/style.cssThe stylesheet
vgrid/reactThe React component VGRID
vgrid/vueThe Vue component vGrid
vgrid/browservGrid, 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.

OptionAcceptsDefaultEffect
elselector or ElementrequiredThe container. A <table> is used as is; any other element has its contents replaced by a new table. Nothing found stops the grid (Troubleshooting).
captionHTML stringnoneA row at the top of the table, beside the toolbar buttons. Inserted as HTML.
namespacestringel's idAdds <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.
autoDestroytrue | falsefalseDestroys 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

OptionAcceptsDefaultEffect
dataarray 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.

OptionAcceptsDefaultEffect
dataSourceURLnoneMakes 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.
headersobject{}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.

ParameterSent whenExample
pagealwayspage=2
limita page size is setlimit=25
sort, dira column is sorted; with a secondary sort (Shift+activate a heading) both are comma-separated lists in priority ordersort=total&dir=desc
sort=status,total&dir=asc,desc
columnssome columns are hidden; lists the visible onescolumns=number,total
a column nameits filter has a valuestatus=paid
a filter-mode keya filter mode is chosen; a range sends each filled end under its own keyfrom_date=2026-01-01
an externalFilters keythat page input has a valueregion=eu
downloada server downloaddownload=true
langalways; 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
}
FieldRequiredEffect
datayesThe rows, keyed by columns[].name. Anything but an array shows an error in place of the rows.
totalno; defaults to data.lengthRows matching the filters, across all pages. Drives "Showing X–Y of Z" and the page count.
pageno; defaults to the requested pageThe page shown. Send it to correct a page that no longer exists.
limitnoThe 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:

CauseMessage
Status other than 2xxThe server answered <status>.
Body is not JSONThe response is not valid JSON.
Body is an array or nullThe response is not an object carrying a "data" array.
No dataThe response has no "data" field.
data is not an arrayThe "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.

OptionAcceptsDefaultEffect
externalSummaryobject 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.

OptionAcceptsDefaultEffect
localea language tag, such as 'en', 'de-AT' or 'pt-BR'found on the pageThe 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:

#SourceExample
1locale in the grid's optionsvGrid({ locale: 'de-AT' })
2The nearest lang attribute: on the grid's element or any element around it, up to <html><section lang="es">, <html lang="de-AT">
3The reader's browser languages, in the order the reader set themfr-FR
4None of the abovelang=, 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

RequestWhere lang is
Rows, GETquery string: /api/orders?page=1&lang=de-AT
Rows, POSTJSON body: { "page": 1, "lang": "de-AT" }
Server downloadthe same as rows
HTML and text panelsquery string, whatever the panel's method: /api/order-notes?id=42&lang=de-AT
Nested grid panelsquery 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

KeyAcceptsDefaultEffect
namestringrequiredThe row key shown in this column; also its sort value and filter parameter. Reserved names stop the grid.
labelstringnameHeader text, filter placeholder, Settings list, external sort list, CSV and print heading.
type'html' | 'enum' | 'link'plain texthtml: 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.
hrefrow field name'link'Which row field holds the address for type: 'link', so a row can have several link columns.
optionsobject of value: textnoneThe lookup for type: 'enum'. Local sorting and filtering use the shown text.
render(value, row) => text | Node | undefinednoneDecides 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.
widthnumber, '120', '120px', or any <th width> valueautoPixel values set the starting width; other values (such as '20%') size the column until the first layout.
activetrue | falsetruefalse hides the column at start; Settings can show it. At least one column must stay visible. Saved Settings win.
isRowKeytrue | falsefalseThe row-number cell shows this column's value. One column at most; two stop the grid.

Per-column sorting and filtering

KeyAcceptsDefaultEffect
sorttrue | falsetruefalse makes the header unclickable and leaves the column out of the external sort list.
filtertrue | falsetruefalse leaves its filter cell empty.
filterType'select'text boxA drop-down built from filterOptions.
filterOptionsstring "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.
filterCleartrue | falsetruefalse removes this filter's × button.
filterModesobjectnoneExact 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.

OptionAcceptsDefaultEffect
rowNumberstrue | falsetrueShows 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.

OptionAcceptsDefaultEffect
sortBy{ column, order }, or an array of themnoneThe 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.

OptionAcceptsDefaultEffect
filterRowtrue | falsetrueShows the filter row.
filterCleartrue | falsetrueA × 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.

OptionAcceptsDefaultEffect
externalFiltersobject 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.

KeyAcceptsDefaultEffect
filterModes.<param>label, or { label, symbol, value }noneA mode that sends <param>.
filterModes.<param>.labeltext, HTML string or Elementthe parameter nameMenu text and button tooltip.
filterModes.<param>.symbolstringnoneButton and menu symbol; without it the button shows the label's first letter.
filterModes.<param>.valuenon-empty text or numbernoneMakes it a one-click mode that sends this value; see One-click modes.
filterModes.range{ keys, label, symbol }noneTwo boxes, "from" and "to".
filterModes.range.keystwo different parameter namesrequired for rangeSent for "from" and "to".
filterModes.range.labeltext, HTML string or Elementthe keys, from – toMenu text and button tooltip.
filterModes.range.symbolstringnoneAs 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: false there 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

OptionAcceptsDefaultEffect
rowsPerPagepositive number0, no paging; 10 with externalPagination or externalRowsPerPageRows per page, sent as limit.
rowsPerPageOptionsarray of positive numbersautomaticThe 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.

OptionAcceptsDefaultEffect
showPaginationControlstrue | falsetrueThe default for the two below.
showPaginationtrue | falseshowPaginationControlsThe count and the pager.
showRowsPerPagetrue | falseshowPaginationControls"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.

OptionAcceptsDefaultEffect
externalPaginationselector, Element, array or NodeListnoneEach container's content is replaced by the count and the pager. A selector takes every element it matches.
externalRowsPerPageselector, Element, array or NodeListnoneYour <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.

OptionAcceptsDefaultEffect
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.
stripedtrue | falsetrueShades every other row.
hovertrue | falsetrueHighlights the row under the pointer.
rowClassrow => stringnoneClasses added to each row, from its data; an empty return adds none.
easingmilliseconds160How 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.

OptionAcceptsDefaultEffect
reorderColumnstrue | falsetrueLets readers move columns. Escape cancels a drag.
reorderDelaymilliseconds350How 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

OptionAcceptsDefaultEffect
settingstrue | falsetrueThe Settings popup, plus Clear filters and Refresh.
downloadtrue | 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.
printtrue | falsetruePrints the rows on screen as a plain table.
externalSettingsselector or ElementnoneOlder 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:

KeyAcceptsDefaultEffect
position'caption' | 'bottom' | 'none' | 'external''caption'bottom: a row under the table. external needs element; without one the buttons stay in the caption.
elementselector or ElementnoneYour container for the buttons; its content is replaced. When set, it wins over position.
orderarray of action names['clear', 'download', 'print', 'refresh', 'settings', 'textSize']Listed actions first, the rest in default order.
clearaction valuefollows settingsClear filters ✕.
downloadaction value, 'server' or 'view'follows downloadDownload ⤓; overrides the top-level download.
printaction valuefollows printPrint ⤒.
refreshaction valuefollows settingsRefresh ↻: reloads remote rows, redraws local ones.
settingsaction valuefollows settingsSettings ⚙.
textSizetrue | false | objecttrueA− 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:

KeyAcceptsDefaultEffect
activetrue | falsetrue when the object is givenfalse hides the button.
symbolstring✕ ⤓ ⤒ ↻ ⚙The button text.
titlestring"Clear filters", "Download", "Print", "Refresh", "Settings"The tooltip.
htmlHTML string or ElementnoneThe button content, inserted as HTML; wins over symbol.
classNamestringnoneClasses on the button.
mode'server' | 'view''server' with a dataSource, else 'view'download only.
decrease, increasestringA−, A+textSize only: the button texts.
decreaseTitle, increaseTitlestring"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.

KeyAcceptsDefaultEffect
panelsarray of panel objectsrequiredThe 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.
singletrue | falsefalsetrue keeps one panel open in the whole grid.
opentrue | panel namenoneThe entry focused first in the menu (true: the first). It opens nothing by itself.
elHTML string+ / -, ⋮ for a menuYour own toggle markup; its first element is used and made a keyboard button.
method'GET' | 'POST''GET'The default for every panel.
headersobjectthe grid's headersThe default for every panel.
rowsPerPagepositive number10The default for every grid panel.
externalFiltersobject of name: selector or ElementnoneThe default for every panel.
subgrid: [
    { position: 'start', panels: [/* … */] },
    { position: 'end', single: true, el: '<button type="button">Details</button>', panels: [/* … */] }
],

Panels

KeyAcceptsDefaultEffect
namestringrequiredThe panel's identity; used by open.
labelstringnameMenu text, toggle tooltip and messages.
idrow field namerequiredThe row field whose value is sent. Rows without a value show no toggle for this panel.
dataSourceURLrequiredRequested as dataSource?id=<value>.
idInPathtrue | falsefalseRequests dataSource/<value> instead.
type'grid' | 'html'plain textgrid: a nested grid. html: the answer inserted as HTML. Omitted: the answer shown as text.
captionHTML string | falsenoneA heading above the content; the × close button is always there.
method'GET' | 'POST'the group's, else 'GET'
headersobjectthe group's, else the grid's
externalFiltersobject of name: selector or Elementthe group'sYour 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:

KeyDefault
columns[], with every column key
rowsPerPagethe group's, else 10
rowNumbers, rowNumbersType, borders, striped, hover, easing, reorderColumns, reorderDelaythe parent grid's value
size'medium', not inherited

Nested grids have no Settings or toolbar, and share the parent's namespace.

Query string inheritance

OptionAcceptsDefaultEffect
inheritQueryStringtrue | falsefalseAdds 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

OptionAcceptsDefaultEffect
onRowClickfunctionnoneCalled with { row, event } when a row is clicked, or Enter or Space is pressed on a focused row.
onRowDblClickfunctionnoneCalled 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.

OptionAcceptsDefaultEffect
rowActionsarray of actions, or a function of the row returning onefalseAdds the Actions column. A function decides per row which buttons appear.
Action keyAcceptsDefaultEffect
labeltext'Action'The button's text.
nametextthe labelIdentifies the action: it is set as data-vgrid-row-action on the button and passed to onClick.
onClickfunctionrequiredCalled with { row, event, grid, name }; grid is the instance.
ariaLabeltextthe labelThe accessible name, for icon-like labels.
disabledtrue | falsefalseRenders 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.

OptionAcceptsDefaultEffect
rowReordertrue | falsefalseAdds the Row order column.
onRowReorderfunctionnoneCalled 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

OptionAcceptsDefaultEffect
cellSelectiontrue | falsefalseCells 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.

OptionCalled whenArgument
onReadyrows 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 }
onSortthe 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
onFiltera filter changes or Clear filters runs. Local grids only; on a remote grid use onReady{ filters: [ { column, value } ] }
onPagea page is shown, when rowsPerPage is set{ page, rowsPerPage, total }
onRowClick, onRowDblClicka row is clicked or double-clicked, or Enter / Space / Shift+Enter on a focused row. See Row click and double-click{ row, event }
onRowReordera 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.

MemberEffect
elementThe grid's <table>.
configThe 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

OptionHook
bordersdata-vgrid-borders="all | horizontal | rows | inner | none" on .vgrid-wrapper
sizedata-vgrid-size="smallest | small | medium | large | larger | largest" on .vgrid-wrapper
stripeddata-vgrid-striped on .vgrid-wrapper; shaded rows carry data-vgrid-stripe
hoverdata-vgrid-hover on .vgrid-wrapper
reorderColumnsdata-vgrid-reorder on .vgrid-wrapper
onRowClick, onRowDblClickdata-vgrid-clickable on each tr.vgrid-row
cellSelectionaria-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.

VariableDefaultAffects
--vgrid-border-color#dee2e6Lines, frames and popup borders
--vgrid-border-width1pxEvery line
--vgrid-border-h--vgrid-border-widthLines between rows
--vgrid-border-v--vgrid-border-width; 0 under horizontal, rows, innerLines between columns
--vgrid-border-radius.25remFrame and popup corners
--vgrid-font-sizeset by size, .75em to 1.5emThe grid's text; override with a rule more specific than .vgrid-wrapper[data-vgrid-size]
--vgrid-hover-colorrgba(128, 128, 128, .18)The row under the pointer
--vgrid-stripe-colorrgba(128, 128, 128, .07)Shaded rows
--vgrid-popup-backgroundCanvasSettings, menus and the loading badge
--vgrid-popup-shadow0 .4em 1.4em rgba(0, 0, 0, .18)Settings, menus and the loading badge
--vgrid-drag-shadowa soft two-layer shadowThe heading being dragged
--vgrid-drop-colorthe system accent colour, fadedThe moved column's brief highlight after the drop
--vgrid-scrollbar-colorthe text colour, fadedScrollbars of Settings, menus and panels
--vgrid-subgrid-max-height400pxThe 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.

ClassElement
vgrid-captionThe caption row
vgrid-actionsThe button group in the caption or bottom row
vgrid-actions-rowThe bottom row holding the buttons
vgrid-settings, vgrid-settings-close, vgrid-settings-title, vgrid-settings-list, vgrid-settings-actionsThe Settings popup, its ×, its heading, its scrolling column list, its Apply and Save
vgrid-headerThe heading row
vgrid-filterThe filter row
vgrid-filter-<name>One column's filter box or drop-down
vgrid-filter-clearA filter's × button
vgrid-filter-modedA 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-symbolThe mode menu, an entry, an entry's symbol
vgrid-filter-fixedThe read-only box of a one-click mode
vgrid-rowA data row; with a namespace and row.id, also <namespace>-row-<id>
vgrid-row-actions, vgrid-row-reorderA row's Actions cell, its Row order cell; buttons carry data-vgrid-row-action="<name>" and data-vgrid-row-reorder="up | down"
vgrid-emptyThe "No records to show." row
vgrid-loading-spaceRoom for the loading badge while there are no rows
vgrid-pagination, vgrid-pagination-barA 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-columnThe <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-itemThe panel menu and its entries
vgrid-subgrid-cell, vgrid-subgrid-slide, vgrid-subgrid-clip, vgrid-subgrid-bodyAn open panel, outside in
vgrid-subgrid-caption, vgrid-subgrid-title, vgrid-subgrid-closeA panel's heading bar, its text, its ×
vgrid-panel-loading, vgrid-panel-empty, vgrid-panel-error, vgrid-panel-textA panel's messages, and a plain-text answer
vgrid-column-ghost, vgrid-column-markerThe dragged heading, the drop line
vgridThe <table>; fixed
vgrid-wrapperThe element around the table; fixed
vgrid-loading-badge, vgrid-loading-errorThe loading badge, and the badge showing an error; fixed
vgrid-spinner, vgrid-loading-textThe 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.el markup is made focusable with role="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; with cellSelection, arrow keys move between cells and Ctrl+C or Command+C copies.
  • Announced: aria-sort on the sorted heading, aria-expanded on toggles, aria-current on 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: 0 turns 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, panel caption, subgrid.el, filterModes labels, and action html. Everything else is inserted as text.
  • Links: type: 'link' links only to paths that start with a single /, and to http: and https: addresses; anything else, such as javascript: or //other.host, shows as text. A node returned by render is 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: headers go to every grid and panel request, other origins included; the CSRF token goes to the same origin only.
{ "data": [ { "notes": "<b>Paid</b> by &lt;script&gt;" } ] }

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.

MessageCause and fix
Setup
vGrid: "el" must identify an element. stopsThe 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 reasonA 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. stopsRename the column or external filter. Reserved names
vGrid: only one column can use isRowKey: true. stopsKeep 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. stopsLeave 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

Row numbers

Sorting

Filtering

Pagination

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' },

More in The button group

active column key

Hides the column at start when false; Settings can show it. Default: true.

{ name: 'ref', active: false }

More in Identity and display

autoDestroy

Destroys the grid once its container has left the page. Default: false.

autoDestroy: true,

More in Mounting and identity

borders

Chooses which lines the grid draws. Default: 'horizontal'.

borders: 'all',

More in Appearance

caption

Adds a caption row at the top of the table, inserted as HTML. Default: none.

caption: 'Orders',

More in Mounting and identity

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' }],

More in Columns

data

Gives the grid its rows in the browser. Default: [].

data: [{ number: 'A-1001', total: 120 }],

More in Local data

dataSource

Makes the grid remote: your server pages, sorts and filters. Default: none.

dataSource: '/api/orders',

More in Remote data

download

Adds a Download button, served by your server or made in the browser. Default: false.

download: 'server',

More in Feature switches

easing

Sets how long the grid animates, in milliseconds. Default: 160.

easing: 0, // no animation

More in Appearance

el

Names the element the grid goes into. Default: required.

el: '#orders',

More in Mounting and identity

externalFilters

Sends your page's own inputs with every request. Default: {}.

externalFilters: { region: '#region' },

More in External filters

externalPagination

Puts the count and the pager in your own elements. Default: none.

externalPagination: '.pager',

More in External pagination

externalRowsPerPage

Lets your own <select> set the page size. Default: none.

externalRowsPerPage: '#page-size',

More in External pagination

externalSettings

The older name for actions.element. Default: none.

externalSettings: '#toolbar',

More in Feature switches

externalSort

Lets your own <select> elements drive the sort. Keys: column, direction. Default: {}.

externalSort: { column: '#sort-column', direction: '#sort-direction' },

More in Sorting

externalSummary

Fills your page's elements with totals your server sends. Default: {}.

externalSummary: { revenue: '#revenue' },

More in Server totals

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,

More in Filter row

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' } }

More in Filter modes

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,

More in Filter row

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 },

More in Remote data

hover

Highlights the row under the pointer. Default: true.

hover: false,

More in Appearance

href column key

Names the row field holding the address of a type: 'link' column. Default: 'link'.

{ name: 'customer', type: 'link', href: 'customerUrl' }

More in Identity and display

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 }

More in Identity and display

label column key

Sets the column heading. Default: name.

{ name: 'number', label: 'Order' }

More in Identity and display

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',

More in Language

method

Sends requests as GET, or as a JSON body with POST. Default: 'GET'.

method: 'POST',

More in Remote data

name column key

Names the row key the column shows. Default: required.

{ name: 'number' }

More in Identity and display

namespace

Names the grid's extra classes and its fallback file names. Default: el's id.

namespace: 'orders-grid',

More in Mounting and identity

onFilter

Called when a filter changes on a local grid. Default: none.

onFilter: ({ filters }) => console.log(filters),

More in Events

onPage

Called when a page is shown. Default: none.

onPage: ({ page, total }) => console.log(page, total),

More in Events

onReady

Called when rows are on screen, or when a remote load fails. Default: none.

onReady: ({ total, error }) => console.log(total, error),

More in Events

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),

More in Row reorder

onSort

Called when the sort changes; with a secondary sort the object also has sorts. Default: none.

onSort: ({ column, order }) => console.log(column, order),

More in Events

options column key

Maps values to shown text for type: 'enum'. Default: none.

{ name: 'status', type: 'enum', options: { paid: 'Paid' } }

More in Identity and display

print

Adds a Print button for the rows on screen. Default: true.

print: false,

More in Feature switches

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) }

More in Identity and display

reorderColumns

Lets readers move columns by holding and dragging a heading. Default: true.

reorderColumns: false,

More in Reordering

reorderDelay

Sets how long to hold a heading before it moves, in milliseconds. Default: 350.

reorderDelay: 600,

More in Reordering

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) }],

More in Row actions

rowReorder

Adds a column of ↑ and ↓ buttons that move a row one place. Default: false.

rowReorder: true,

More in Row reorder

rowClass

Returns classes to add to a row, from its data. Default: none.

rowClass: row => row.status === 'overdue' ? 'table-danger' : '',

More in Appearance

rowNumbers

Shows a leading column of row numbers. Default: true.

rowNumbers: false,

More in Row numbers

rowNumbersType

Counts the rows, or shows a row field, in the number column. Default: 'sequential'.

rowNumbersType: 'id',

More in Row numbers

rowsPerPage

Sets how many rows each page shows; the server receives it as limit. Default: 0, no paging.

rowsPerPage: 25,

More in Page size

rowsPerPageOptions

Sets the choices in "Show N rows". Default: automatic.

rowsPerPageOptions: [25, 50, 100],

More in Page size

settings

Adds the Settings popup, Clear filters and Refresh. Default: true.

settings: false,

More in Feature switches

showPagination

Shows the count and the pager. Default: showPaginationControls.

showPagination: false,

More in Pagination bars

showPaginationControls

Shows or hides both parts of the pagination bars. Default: true.

showPaginationControls: false,

More in Pagination bars

showRowsPerPage

Shows "Show N rows". Default: showPaginationControls.

showRowsPerPage: false,

More in Pagination bars

size

Sets the starting text size. Default: 'medium'.

size: 'small',

More in Appearance

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' }],

More in Sorting

striped

Shades every other row. Default: true.

striped: false,

More in Appearance

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' }] },

More in Subgrids

type column key

Shows the value as HTML, as looked-up text, or as a link. Default: plain text.

{ name: 'notes', type: 'html' }

More in Identity and display

width column key

Sets the column's starting width. Default: auto.

{ name: 'number', width: 120 }

More in Identity and display