Vanilla Grid Component

A lightweight, high-performance virtualized data grid component built with vanilla JavaScript (no dependencies).

Features

Installation

Direct File Include

<script src="path/to/vanilla-grid/vanilla-grid.js"></script>

vanilla-grid.js is the only tag needed: it auto-loads, relative to its own URL, every features/*.js module, the data managers (data-managers/data-manager.js, odata-data-manager.js, graphql-data-manager.js, static-data-manager.js) and, last, the <vn-grid> wrapper vanilla-grid-element.js. Await window.VanillaGridReady before using any of them. For a single-file deployment use the built vanilla-grid.bundle.js instead (it sets window.VanillaGridSkipAutoload = true internally).

When using the <vn-grid> web component, you do not need to add <link> tags for vanilla-grid.css or the theme files. The component automatically injects the base structural stylesheet and the active theme stylesheet into <head>.

If you are using VanillaGrid directly (without the web component), include the CSS manually:

<link rel="stylesheet" href="path/to/vanilla-grid/vanilla-grid.css">
<link rel="stylesheet" href="path/to/vanilla-grid/themes/vn-grid-default.css">

Usage

Web Component Setup

The custom element is designed as a light-DOM wrapper around VanillaGrid. It does not use Shadow DOM, so existing app and theme CSS continue to apply.

Markup declares presentation; data always comes from a DataManager (see DataManager):

<vn-grid
    id="myGrid"
    theme="default"
    locale="en-US"
    infinite-scroll="true"></vn-grid>

Theme Attributes

Attribute Type Default Description
theme string 'default' Built-in theme name: default, material, fiori, carbon, carbon-dark, glow, glow-dark, fluent (or a name added with VanillaGridElement.registerTheme())
theme-css-path string Custom CSS file path. When set, overrides the theme attribute

Supported themes are also available programmatically:

// List all built-in themes
console.log(VanillaGridElement.SUPPORTED_THEMES);
// → ['default', 'material', 'fiori', 'carbon', 'carbon-dark', 'glow', 'glow-dark', 'fluent']

// Switch theme at runtime
document.getElementById('myGrid').setTheme('carbon-dark');

// Or use a custom theme CSS file
document.getElementById('myGrid').themeCssPath = 'my-themes/grid-custom.css';

Attach a DataManager, initialize, then load:

await window.VanillaGridReady;
const myGridElement = document.getElementById('myGrid');

myGridElement.setDataManager(new ODataDataManager({
    baseUrl: 'http://localhost:3000/api/items',
    countUrl: 'http://localhost:3000/api/items/$count',
    headers: () => ({ Accept: 'application/json' }),
    pageSize: 1000
}));

const grid = myGridElement.initializeGrid({
    layout: {
        rowHeight: 24,
        bufferRows: 20,
    },
    infiniteScroll: { enabled: true }
});

await myGridElement.loadRowsAsync();

Request headers and fetch side effects are DataManager concerns (headers, onRowsLoaded, onFetchResponse, …); per-cell DOM is a column's renderCell.

Basic Setup

<div class="table-container">
    <div class="table-header-spacer">
        <table class="header-table">
            <colgroup id="myColGroup"></colgroup>
            <thead id="myTableHeader"></thead>
        </table>
    </div>
    <div id="myViewport" class="virtual-list-viewport">
        <table class="body-table">
            <colgroup></colgroup>
            <tbody id="myTableBody"></tbody>
        </table>
    </div>
</div>
const grid = new VanillaGrid({
    header: document.getElementById('myTableHeader'),
    body: document.getElementById('myTableBody'),
    headerColGroup: document.getElementById('myColGroup'),
    viewport: document.getElementById('myViewport'),
    headerSpacer: document.querySelector('.table-header-spacer'),
    formatting: {
        locale: 'en-US',
        emptyMessage: 'No data available'
    },
    layout: {
        rowHeight: 24,
        snapViewportToRows: true,
        bufferRows: 20,
        scrollSpeedMultiplier: 1.35
    }
});

// Set columns
grid.setColumns([
    { key: 'id', label: 'ID', width: 60 },
    { key: 'name', label: 'Name', width: 200 },
    { key: 'email', label: 'Email', width: 250 }
]);

// Load data
grid.setRows([
    { id: 1, name: 'John Doe', email: 'john@example.com' },
    { id: 2, name: 'Jane Smith', email: 'jane@example.com' }
]);

Configuration Options

DOM References (top-level, required)

Option Type Default Description
header HTMLElement required Table <thead> element
body HTMLElement required Table <tbody> element
viewport HTMLElement required Scrollable viewport container
headerColGroup HTMLElement required <colgroup> for header columns
headerSpacer HTMLElement required Spacer div for scrollbar alignment
groupBar HTMLElement optional Strip the row-grouping group bar renders into. <vn-grid> always supplies one; omit it and the grid creates the strip itself as the first child of its .vn-grid-table-container. Only a grid with no resolvable container keeps its grouped columns visible instead of removing them

Cell text is formatted by column type (with per-column formatOptions), and type: 'number' columns are right-aligned; per-cell DOM customization is the column's renderCell(cell, value, row, rowIndex) (see Column Definition).

formatting Group

Option Type Default Description
formatting.locale string navigator.language Locale used by default number formatting
formatting.emptyMessage string 'No rows to display.' Message shown when there is no data
formatting.messages object see below Localizable labels for built-in UI text
formatting.getHeaderMainText function column.label ?? column.key Override the primary header label: (column) => string
formatting.getHeaderSecondaryText function [column.secondaryLabel] Override the bracketed secondary header text: (column) => string
formatting.formatInteger function Intl.NumberFormat Formats row counter integers in the scroll indicator
formatting.formatScrollIndicator function null Fully custom scroll position text formatter

layout Group

Option Type Default Description
layout.rowHeight number 24 Height of each row in pixels
layout.headerHeight number theme default Explicit column-header height in pixels. When set, overrides the active theme's --vn-grid-header-height. When omitted, the grid adopts the theme value (32px default, 40px Glow, 42px Fluent, 44px Fiori, 48px Carbon, 56px Material).
layout.snapViewportToRows boolean true Snap viewport body height to a multiple of row height to avoid partial-row viewport artifacts
layout.bufferRows number 20 (40 on pointer: coarse devices) Extra rows rendered above/below the visible area for smooth scrolling; skewed toward the scroll direction under high recent velocity (e.g. touch momentum)
layout.scrollSpeedMultiplier number 1.35 Wheel scroll speed multiplier (1.0 = slower, 1.8 = faster)
layout.scrollSpeed string 'standard' Legacy preset alias: 'slow', 'standard', 'fast' (ignored when scrollSpeedMultiplier is set)
layout.customScrollbar boolean true Use the custom JavaScript vertical scrollbar; set false for native browser scrollbar
layout.stretchToFit boolean false When true, grow the last resizable column so the column-width sum fills the viewport exactly. When false (default) the table stays at its natural width: leftover space is left empty and an oversized column sum produces a horizontal scrollbar. Can also be set as the top-level option stretchToFit or via the stretch-to-fit attribute on <vn-grid>.

sorting Group

Option Type Default Description
sorting.enabled boolean true Enable column sorting
sorting.serverSide boolean false Skip client-side re-sort; delegate to the DataManager's handleSort() (which fires a config-change → auto-reload re-fetches page 0) or to the host's onSort
sorting.onSort function null Sort callback: (column, direction, sortState) => void
sorting.compareValues function built-in Custom sort comparator: (a, b, column) => number. Must be a consistent ordering (transitive, 0 exactly for values that belong together) — grouping finds group boundaries with it. May order by any column property, including host-invented ones, except the display-only ones (labels, sizing, renderCell, formatOptions, timeZone, sortable, filter properties): setColumns() re-sorts when such a property of a sorted or grouped column changes. A comparator that depends on a display-only property or on state outside the column must re-sort itself with sortColumns(getSortState().sortColumns, { preserveScroll: true }), which also calls onSort and persists the sort state.
sorting.shimmerThreshold number 5000 Show the loading shimmer and defer a local sort by one animation frame when rows.length meets or exceeds this threshold (so very large datasets don't appear to freeze the UI). Set to 0 to always shimmer, false / Infinity / a negative number to disable. Server-sort mode is unaffected.
sorting.columns array undefined Initial sort chain, e.g. [{ key: 'lastName', direction: 'asc' }, { key: 'salary', direction: 'desc' }] — any number of columns, primary first. Key-based (unlike the index-based sortColumns() API). Also settable declaratively via the sort-by attribute on <vn-grid>. Entries naming an unknown, hidden or non-sortable column are skipped with a warning. A default, not a lock: the user's persisted sort — including "no sort" — wins afterwards.
sorting.useWorker boolean true Allow large local sorts to be offloaded to a lazily-created Web Worker so the main thread stays responsive. Automatically skipped when a custom compareValues is configured (functions can't cross the worker boundary) or when Worker is unavailable.
sorting.workerThreshold number 50000 Row-count threshold above which the sort is dispatched to the Web Worker. Same parsing as shimmerThreshold (false / Infinity / negative → disabled).

grouping Group

Client-side, multi-level (any number of ordered group columns). See Row Grouping Implementation.

Option Type Default Description
grouping.columns array undefined Initial group state, e.g. [{ key: 'country', direction: 'asc' }, { key: 'city', direction: 'desc' }] — any number of ordered levels, outermost first. Also settable declaratively via the group-by attribute on <vn-grid>. A default, not a lock: the user's persisted grouping — including "no grouping" — wins afterwards.
grouping.expandMode string 'expanded' Whether a newly-encountered group starts 'expanded' or 'collapsed'. After expandAllGroups()/collapseAllGroups(), groups that appear later start in that state instead, until the grouping is fully cleared
grouping.showCount boolean true Include the member count in each caption's label

Persistence: persistence.groupState ({ enabled, storageKey }) persists the requested group state across reloads, mirroring persistence.sortState — see the persistence group below.

columns Group

Option Type Default Description
columns.reorderable boolean true Enable drag-and-drop column reordering
columns.touchReorder boolean true Enable touch hold-to-reorder on mobile
columns.touchReorderHoldDelay number 220 Milliseconds to hold before touch reorder activates
columns.reorderMarkerDeadZonePx number 20 Dead-zone in pixels around the center of a column before the drop indicator flips sides

persistence Group

Option Type Default Description
persistence.columnWidths.enabled boolean true Persist resized column widths to localStorage
persistence.columnWidths.storageKey string auto Override the localStorage key for column widths
persistence.columnOrder.enabled boolean true Persist column order to localStorage
persistence.columnOrder.storageKey string auto Override the localStorage key for column order
persistence.hiddenColumns.enabled boolean true Persist hidden column state to localStorage
persistence.hiddenColumns.storageKey string auto Override the localStorage key for hidden columns
persistence.frozenColumns.enabled boolean true Persist frozen column state to localStorage
persistence.frozenColumns.storageKey string auto Override the localStorage key for frozen columns
persistence.sortState.enabled boolean true Persist the active sort chain to localStorage
persistence.sortState.storageKey string auto Override the localStorage key for sort state
persistence.groupState.enabled boolean true Persist the requested group state to localStorage
persistence.groupState.storageKey string auto Override the localStorage key for group state
persistence.filterModel.enabled boolean true Persist the active column-filter model to localStorage
persistence.filterModel.storageKey string auto Override the localStorage key for the filter model
persistence.searchTerm.enabled boolean false Persist the free-text search term to localStorage (opt-in; transient session state)
persistence.searchTerm.storageKey string auto Override the localStorage key for the search term

infiniteScroll Group

Option Type Default Description
infiniteScroll.enabled boolean false Enable infinite-scroll paging mode
infiniteScroll.pageSize number 1000 Rows requested per page in infinite-scroll mode
infiniteScroll.onLoadMore function null Async page loader: (skip, pageSize) => Promise<rows>
infiniteScroll.shimmerDelay number 500 Grace period in ms before the load-more affordances appear during a page fetch — both the shimmer placeholder rows at the tail and the "loading more rows" banner; 0 = immediate, negative = show neither. The .vn-grid-loading-more class is never delayed

Total row count (push, not infinite-scroll-nested)

Option Type Default Description
onTotalRowCountChanged function null Callback fired whenever the total row count for the current query changes: (count|null) => void. Pushed by the attached DataManager (see getTotalRowCount() below) — the grid never pulls/polls for it. Top-level, not nested under infiniteScroll, since it applies to any grid a DataManager is attached to.

selection Group

Option Type Default Description
selection.mode string 'noselection' Row selection mode: 'noselection', 'single', or 'multiple'
selection.rowKeyField string 'id' Field name used as the stable row identity key
selection.rowKeyGetter function null Custom key resolver (highest priority): (row) => string|number
selection.showCheckboxes boolean true Show a checkbox column when selection.mode is 'single' or 'multiple'

messages Default Values

messages: {
    of: 'of',                           // Scroll indicator separator ("1–50 of 1000")
    hideColumn: 'Hide column',          // Header context menu
    showAllColumns: 'Show all columns', // Header context menu
    freezeColumn: 'Freeze column',      // Header context menu
    unfreezeColumn: 'Unfreeze column',  // Header context menu
    unfreezeAll: 'Unfreeze all',        // Header context menu
    loadingMore: 'Loading more rows…',  // Infinite-scroll busy banner
    groupByColumn: 'Group by this column',   // Header context menu (row grouping — replace)
    addToGrouping: 'Add to grouping',        // Header context menu (row grouping — append a level)
    ungroupColumn: 'Remove from grouping',   // Header context menu (row grouping)
    ungroupAll: 'Ungroup all',               // Header context menu + the group bar's trailing command (row grouping — clear every level; shown only while grouped)
    groupReasonDisabled: '…',                // Disabled-menu-item title, keyed by canGroupByColumn()'s/canAddGroupLevel()'s reason code
    groupReasonPartialDataset: '…',          // ditto — dataset not fully loaded
    groupReasonMultiLevelNotYetSupported: '…', // ditto — a second group column
    groupReasonNoVisibleColumnsLeft: '…',    // ditto — grouping would leave no visible data column
    groupItemsSuffix: 'items',               // Caption label suffix ("… — 42 items")
    groupBlankValue: '(Blank)',              // Caption label for a null/undefined group value
    groupPathAriaSeparator: ', ',            // Joins a nested group's path in caption/footer accessible names
    groupBarLabel: 'Grouped by',             // Group bar: leading label + the strip's aria-label
    groupBarRemove: 'Remove from grouping',  // Group bar: a chip's remove button
    groupBarSortAscending: 'Ascending',      // Group bar: a chip's direction control (asc)
    groupBarSortDescending: 'Descending',    // Group bar: a chip's direction control (desc)
    groupBarSuspended: 'Grouping is paused:',// Group bar: tooltip prefix while suspended
    groupBarExpandAll: 'Expand all',         // Group bar: trailing command (disabled while suspended)
    groupBarCollapseAll: 'Collapse all',     // Group bar: trailing command (disabled while suspended)
    aggregateFunctionLabels: {               // Group footer rows: each function's full name — accessible name,
        sum: 'Sum', avg: 'Average',          //   cell tooltip ("Average of Projects for Italy: 14.165") and header menu
        median: 'Median', min: 'Minimum', max: 'Maximum',
        countTrue: 'True count', countDistinct: 'Distinct count',
    },
    aggregateFunctionMarkers: {},            // Text markers (empty by default: built-in functions show SVG icons). An entry replaces the icon
                                             //   ({ avg: 'Ø' }), gives a registered function a marker ({ stdev: 'σ' }), or '' hides it

    aggregateFooterAria: 'Group summary',    // Group footer rows: the leading announcement of the row's accessible name
    aggregateOfLabel: 'of',                  // Group footer rows: joins function to column ("Sum of Projects")
    aggregateForLabel: 'for',                // Group footer rows: joins column to group path in the cell tooltip
    aggregatePathSeparator: ' › ',           // Group footer rows: joins the tooltip's path values (theme-independent; RTL: ' ‹ ')
    aggregateMenuLabel: 'Aggregate',         // Header menu: the submenu's parent entry
    aggregateRemove: 'Remove aggregate',     // Header menu: rendered only while the column carries one
    // Disabled-entry titles, keyed by canAggregateColumn()'s reason code and
    // derived from it the way the groupReason* keys are.
    aggregateReasonNoGrouping: 'Aggregates require an active grouping.',
    aggregateReasonColumnUnsupported: 'This column’s type cannot be aggregated.',
    aggregateReasonColumnNotFound: 'This column cannot be aggregated.',
    aggregateReasonPartialDataset: 'Aggregates require the full result set to be loaded.',
    aggregateReasonDisabled: 'Aggregates are unavailable.',
}

Infinite-scroll busy state

Once a page fetch has been in flight for infiniteScroll.shimmerDelay ms (default 500 — a fast backend paints neither), the grid shows two things:

Set shimmerDelay to a negative number to suppress both and paint your own indicator off the class below, which is never delayed.

Styling hooks:

Hook What it is
.vn-grid-loading-more Class toggled on .vn-grid-table-container for the duration of the fetch, with no grace period — it tracks isBusy()'s load-more half exactly. Style your own indicator off it. Distinct from .vn-grid-shimmer-loading, which means a full load (setLoading(true))
.vn-grid-load-more-banner The banner element (.vn-grid-load-more-banner-visible while shown), with .vn-grid-load-more-banner-shimmer (animated accent) and .vn-grid-load-more-banner-text (label) inside
.vn-grid-load-more-skeleton-row The tail skeleton rows

Both the class and the banner clear between failed retries, since the grid is genuinely idle while it waits out the backoff.

Column Definition

Each column object passed to setColumns() supports the following properties:

Property Alias(es) Description
key field Required. Unique column identifier, also used as the field name when reading row data
label Header display text (falls back to key if omitted)
secondaryLabel secondary-label Secondary header text, shown in brackets after the main label on the same line, e.g. [kg]
width Initial column width in pixels
type Data type (string, number, boolean, date, datetime, time, uid, …) — drives formatting, alignment and the sort comparator. Default 'string'
formatOptions Options forwarded to Intl.NumberFormat (number) or Intl.DateTimeFormat (temporal types)
renderCell Custom cell renderer (cell, value, row, rowIndex) => void — mutate cell in place
sortable Set false to make this column non-sortable (default true)
resizable Set false to suppress the resize handle on this column
filterable Set false to make this column non-filterable (default true) — see Column Filters
highlightSearchMatches Set false to opt this column out of highlightSearchMatches (see below) even while the grid-wide flag is on. undefined/omitted (default) inherits the grid-wide flag. No effect when the grid-wide flag is off
filterFields filter-fields Comma-separated underlying field path(s) to filter on when they differ from key, OR-ed together (fan-out). Covers custom-rendered / remapped columns (one field) and composite columns (several)
filterType filter-type Operator/literal type the filter uses (defaults to type)
filterOperators filter-operators Comma-separated allow-list of filter operators offered for this column (subset of its type catalog, e.g. "in,equals"). Panel dropdown and normalization both honor it; absent/empty = full catalog — see Column Filters
defaultFilterOperator default-filter-operator Operator preselected when the filter panel opens with no active filter on the column (e.g. equals on a string column instead of the contains type-default). Works standalone; when filterOperators is also set it must be within that list
isKeyColumn keyColumn, is-key-column, key-column Mark as the stable selection key column (see Row Selection)

Example:

grid.setColumns([
    { key: 'id',     label: 'ID',     width: 60,  sortable: false },
    { key: 'name',   label: 'Name',   width: 200 },
    { key: 'weight', label: 'Weight', secondaryLabel: 'kg', width: 120 },
    { key: 'active', label: 'Active', width: 80,  isKeyColumn: false }
]);

VanillaGrid Methods

Data Management

State Management

Multi-Column Sorting

These four are index-based. To declare an initial sort — before the grid has columns, from markup or from an options object — use the key-based sort-by attribute or the sorting.columns option instead:

<vn-grid sort-by="lastName:asc, salary:desc"></vn-grid>

Entry order is the sort-chain order, primary column first, and the direction is required on every entry. See Sorting Implementation §12.1.

Column Visibility

All four mutators rebuild the virtual row pool and preserve the current scroll position — hiding or showing a column does not jump the user back to the top of a scrolled grid.

Column Freeze

Column Reordering

Header Runtime

Row Selection

Row Grouping

Client-side, multi-level. See Row Grouping Implementation for the full design — the render projection, availability gating over a partial dataset, and the caption keyboard/focus contract.

Large grids: a group change is a reorder, and reorders defer. A group change moves as much data as a sort, so it runs on the same bracket: below sorting.shimmerThreshold (default 5000 rows) it is fully synchronous; at or above it the reorder is deferred behind the loading skeleton and offloaded to the sort Web Worker when every group level is worker-safe (string/number/boolean; a temporal group level falls back to the shimmered in-thread path). Two consequences for callers:

Events: vn-grid-group-changed, vn-grid-group-expanded, vn-grid-group-collapsed, vn-grid-aggregates-changed (all bubbling + composed — see the implementation doc's Event Contract section).

Group aggregates

A group footer row under each group, at every level, carrying a named reduction of one column's values in that column's own cell. Configured declaratively or at runtime; nothing renders until at least one column carries one.

new VanillaGrid({
    grouping: {
        columns:    [{ key: 'country', direction: 'asc' }],
        aggregates: [{ key: 'projects', fn: 'sum' }]
    }
});

From the header menu. A column's right-click menu carries "Aggregate ▸", opening a flyout of the functions its type admits, plus "Remove aggregate" while one is set. Picking a function on a column that already has one replaces it. The entry is on every column's menu whatever the grid state — so the capability is discoverable before anything is grouped — and is disabled with an explained title when it cannot act: the column's type first ('column-unsupported'), then the grid's state ('no-grouping', or 'partial-dataset' while a grouping is suspended). A column that is ineligible both ways reports its type, since that is the reason grouping will not fix. A grouped column needs no rule: its column has left the grid, so its menu is not on screen.

What a v1 aggregate deliberately does not do, so none of it is a discovery:

Seven functions ship built in, offered by the column's declared type — the gate reads the declaration, never the runtime values, so an untyped or valueGetter column offers nothing. Registration order is menu order.

column.type sum avg median min max countTrue countDistinct
number
date / datetime / time
string, uuid, uid
boolean

Cost at a million rows. In Chrome, on grid-minimal-js grouped by Country ▸ City, a whole aggregate change — the grouped rebuild included — settles in about 0.8–0.9 s for Median of Height plus Distinct count of Department, and about 0.9–1.2 s for Minimum of Hire Date plus True count of Active, with or without the --obfuscate build. Per function, one aggregated column adds roughly: sum, avg, min, max 30–60 ms; median 1.2–1.8× that; countTrue and low-cardinality countDistinct 60–110 ms; temporal min/max on epoch-number columns about the same as sum (per-function figures from a Node harness). Two cases are much dearer; both run behind the loading skeleton and only when someone picks that function on such a column:

Aggregates are single-column: a reducer's step(acc, value) receives a value, not a row, and there is no cross-column expression language.

The reducer contract. { init(context), step(acc, value), finalize(acc), types }, plus optionally:

step() runs once per row per open group level; state size is the reducer's business.

// Sample standard deviation (Welford), skipping gaps like the built-ins.
VanillaGrid.registerAggregate('stdev', {
    init: () => ({ n: 0, mean: 0, m2: 0 }),
    step: (acc, v) => {
        if (typeof v !== 'number' || Number.isNaN(v)) return;
        acc.n++;
        const d = v - acc.mean;
        acc.mean += d / acc.n;
        acc.m2 += d * (v - acc.mean);
    },
    finalize: (acc) => (acc.n > 1 ? Math.sqrt(acc.m2 / (acc.n - 1)) : null),
    types: ['number'],
});

A registered function's label and marker come from messages.aggregateFunctionLabels / aggregateFunctionMarkers, and its marker is text (only the built-ins have icons); with neither, the menu and tooltip show its name, the cell no marker, and the header badge the sum icon. samples/grid-minimal-js/ registers two, stdev and mode, as working examples.

Like a group change, an aggregate change runs on the reorder bracket: fully synchronous below sorting.shimmerThreshold, behind the loading skeleton at or above it. A change arriving while a group change is still deferred does not overtake it — the two coalesce into one settled pipeline, so the projection is always built from rows ordered for the grouping that was asked for, and each settled change is announced once. step() is main-thread work — a host reducer cannot be transferred to the sort Worker. vn-grid-aggregates-changed fires once per settled change and means the computed values have rendered, so getGroupAggregates() read inside the handler is always settled; a superseded recomputation emits nothing.

grid.setAggregates([{ key: 'projects', fn: 'sum' }]);
grid.addEventListener?.('vn-grid-aggregates-changed', () => {
    console.log(grid.getGroupAggregates(['Italy']));   // { projects: 1204 }
});

An aggregate and a group level never share a column. An applied level's column has left the grid, so its total would have no cell to render into — which is why the header menu can never offer one. The API is held to the same rule at both ends: setAggregates() refuses a column the grid is grouped by ('column-grouped'), and grouping by an already-aggregated column prunes that aggregate with one event, rather than leaving a configuration that computes and renders nowhere. An ineligible column type still outranks the conflict.

An aggregate lives and dies with the requested group state — one rule, three cases. Clearing the last group level discards the configuration (there is nothing left to reduce over), emitting one vn-grid-aggregates-changed with the emptied set; removing one level of a multi-level grouping keeps it, since the session is still alive. A suspension retains it with no event and no host action, and resuming recomputes — but while suspended the set is not editable, and setAggregates() refuses with 'disabled'. A setColumns() that removes, renames or retypes an aggregated column prunes that entry, revalidating against the same gate a fresh acquisition passes, and announces the settled set with exactly one event — so no stale key ever reaches a footer cell. All of it persists inside persistence.groupState: levels and aggregates share one key, one flag and one lifetime, and a blob stored before aggregates existed reads back as none configured. A declared grouping.aggregates with no grouping beside it is warned about and ignored.

Grouped columns leave the grid. An applied group level's column is removed from columns — its value lives in the caption row, not in a column repeating it on every member row — and a group bar above the header renders one chip per level with its label, its direction, and an ungroup action. This is unconditional; there is no option for it.

Re-nesting: drag a chip. Chips are draggable within the bar, and dropping one on the other side of a sibling re-nests the grouping — dragging Country before City turns City ▸ Country into Country ▸ City, with the captions, the effective sort and the projection all re-deriving. Alt+ArrowLeft/ArrowRight (or Ctrl+Shift+arrow, for window managers that swallow Alt+arrow) does the same from the keyboard, in the logical direction, with focus following the level it moved. A drop is setGroupState() with a reordered array and nothing else: no new API, no new event, and a drop that would not change the order emits nothing at all. It is not gated on columns.behavior.reorderable — that flag governs column order, not group state. Pointer and keyboard only; touch is not supported for this gesture.

The bar's trailing edge carries three text commands acting on the whole grouping — Expand all, Collapse all and Ungroup all — each one calling the public API (expandAllGroups(), collapseAllGroups(), clearGrouping()), so a bar click is indistinguishable from a host call or a header-menu click. Expand/collapse are disabled while the grouping is suspended (no captions to act on); Ungroup all never is. Their appearance is theme-owned via --vn-grid-group-bar-action-*.

While grouped, a column filter on a grouped column stays applied but can only be edited by ungrouping first (its funnel icon left with the header). The toolbar's hasFilterOrSort predicate and clearColumnFiltersAndSorting command still see and clear it.

Web Component API

vanilla-grid-element.js exposes VanillaGridElement, registered as <vn-grid>.

Declarative Columns

Columns can be declared as child elements instead of (or as a fallback to) calling setColumns() imperatively:

<vn-grid id="myGrid" infinite-scroll="true" selection-mode="multiple" row-key-field="id">
    <vn-grid-column field="id"     label="ID"     width="60"></vn-grid-column>
    <vn-grid-column field="name"   label="Name"   width="200"></vn-grid-column>
    <vn-grid-column field="weight" label="Weight" secondary-label="kg" width="120"></vn-grid-column>
</vn-grid>

<vn-grid-column> attributes map directly to column definition properties: field (or key), label, width, secondary-label, is-key-column.

Observed Attributes

Attribute Type Default Description
environment string '' Host-defined value mirrored into lifecycle-event context.host.environment; the grid never reads it
locale string '' Locale hint passed to the grid (mirrored into lifecycle-event context.locale)
max-rows string '' Host-defined value mirrored into lifecycle-event context.host.maxRows; the grid never reads it
infinite-scroll boolean string 'false' Enables infinite loading mode when 'true'
theme string 'default' Built-in or registered theme name (see Theme Attributes)
theme-css-path string Custom theme CSS path; overrides theme
storage-mode string 'none' Settings persistence backend: 'none', 'local' (localStorage), or 'remote'
empty-message string default text Initial empty-state text rendered before grid init (read once, not observed)
selection-mode string 'noselection' Row selection mode: 'noselection', 'single', or 'multiple'
row-key-field string 'id' Field used as the stable row identity key
stretch-to-fit boolean string 'false' When present (with no value, or 'true'/''), the last resizable column grows to fill the viewport. When omitted or 'false', columns keep their natural / configured / persisted widths.
highlight-search-matches boolean string 'false' When present (with no value, or 'true'/''), every visible cell whose formatted text contains the active search term gets that substring wrapped in <mark class="vn-grid-search-match">. Off by default. Per-column opt-out via column.highlightSearchMatches: false. See docs/vanilla-grid/02-row-virtualization-and-custom-scroll-implementation.md §1.17.
row-height number (px) theme default Explicit body row height. When set (positive finite number), takes precedence over the active theme's --vn-grid-row-height and is forwarded as layout.rowHeight. When omitted, the grid adopts the theme's row height (24px default, 32px Fiori/Fluent/Glow, 36px Material, 48px Carbon).
header-height number (px) theme default Explicit column-header height. When set (positive finite number), takes precedence over the active theme's --vn-grid-header-height and is forwarded as layout.headerHeight. When omitted, the grid adopts the theme's header height (32px default, 40px Glow, 42px Fluent, 44px Fiori, 48px Carbon, 56px Material).
group-by string unset Initial multi-level grouping — a comma-separated key:direction list, outermost level first (e.g. "country:asc, city:desc"). The direction is required on every entry; an invalid entry is skipped with a warning while the rest still apply. Forwarded as grouping.columns at initializeGrid() time and read once — use groupByColumn() / setGroupState() on the live grid for runtime changes.
sort-by string unset Initial multi-column sort — the same grammar as group-by, with entry order meaning sort-chain order, primary column first (e.g. "lastName:asc, salary:desc"). Forwarded as sorting.columns and read once — use sortColumns() / clearSort() afterwards. Entries naming an unknown, hidden or non-sortable column are skipped with a warning.

Element Properties

Property Description
selectionMode Get/set the selection mode ('noselection', 'single', 'multiple')
rowKeyField Get/set the row key field name
rowKeyGetter Set a custom key resolver function (row) => string|number
rowHeight Get/set the explicit row height in pixels (mirrors the row-height attribute). Returns null when unset.
headerHeight Get/set the explicit column-header height in pixels (mirrors the header-height attribute). Returns null when unset.
groupBy Get/set the raw declarative grouping list (mirrors the group-by attribute), e.g. 'country:asc, city:desc'. Returns null when unset.
sortBy Get/set the raw declarative initial-sort list (mirrors the sort-by attribute), e.g. 'lastName:asc, salary:desc'. Returns null when unset.
highlightSearchMatches Read-only; reflects the highlight-search-matches attribute.

Element Methods

DataManager Hooks

The element has no fetch logic of its own — every load goes through the attached DataManager (see DataManager). A custom subclass overrides:

Element Events

Column Resizing

Real-time Feedback

During a column resize drag, the body columns update in lock-step with the header. Both the header and body tables are kept at an explicit pixel width matching the sum of all column widths, ensuring table-layout: fixed distributes space immediately on every animation frame.

Last Column

Every column — including the last — has a resize handle on its right edge. The last column resizes independently (not paired with a neighbour), so dragging its handle left shrinks it without affecting other columns. After release, the table width is recalculated:

Resize Guide Overlay

A thin vertical guide line appears during column resize drag:

Horizontal Scrolling

When the total column width exceeds the viewport, a custom horizontal scrollbar appears at the bottom of the grid. The header table stays aligned with the body during horizontal scroll via a GPU-accelerated transform: translateX() applied on every scroll event.

The custom vertical scrollbar remains active alongside the horizontal one. Both scrollbars are styled to match via CSS custom properties and WebKit pseudo-element rules.

Custom Scrollbars

The grid replaces native scrollbars with JavaScript-driven custom scrollbars (vertical and horizontal):

Column Reordering

Users can rearrange columns by dragging a header to a new position. A drop indicator line shows the insertion point.

Mouse Drag

Click and hold a header cell, then drag left or right. Release to drop.

Touch Reorder

On touch devices, press and hold a header for touchReorderHoldDelay milliseconds (default: 220 ms) to activate reorder mode, then drag.

Disable touch reorder with columns: { touchReorder: false }.

Frozen Column Boundary Enforcement

Frozen and unfrozen columns form separate groups. You can reorder within a group freely, but dragging across the frozen/unfrozen boundary is blocked. To move a column across the boundary, use the context menu to freeze or unfreeze it first.

Persistence

Column order is saved to localStorage automatically when persistence.columnOrder.enabled is true (default). The key is derived from the table/viewport/header element IDs and the page path, or can be overridden with persistence.columnOrder.storageKey.

Programmatic Reordering

grid.reorderColumn(fromIndex, toIndex, { dropBefore: true });

Column Visibility

Individual columns can be hidden and shown again without removing them from the column definition.

Context Menu

Right-click any column header to open the context menu. Select Hide column to hide the current column. If any columns are hidden, a Show all columns option appears to restore them all.

API

grid.hideColumn('weight');        // Hide by key
grid.showColumn('weight');        // Restore by key
grid.showAllColumns();            // Restore all hidden columns
grid.getHiddenColumns();          // ['weight', ...]
grid.canHideColumn('name');       // false when it is the last visible column

Behavior

Persistence

Hidden column state is saved to localStorage when persistence.hiddenColumns.enabled is true (default). Override the key with persistence.hiddenColumns.storageKey.

Localization

messages: {
    hideColumn: 'Hide column',
    showAllColumns: 'Show all columns'
}

Column Freeze / Unfreeze

Frozen columns stay anchored at the left edge of the viewport and never scroll out of view horizontally. A blue vertical guide line marks the freeze boundary.

Context Menu

Right-click a column header to open the context menu. Select Freeze column to freeze it, Unfreeze column to release it, or Unfreeze all when multiple columns are frozen.

API

grid.freezeColumn('name');         // Freeze by key
grid.unfreezeColumn('name');       // Unfreeze by key
grid.unfreezeAll();                // Release all frozen columns
grid.isFrozenColumn('name');       // true / false
grid.getFrozenColumns();           // ['name', ...]

Behavior

Persistence

Frozen column state is saved to localStorage when persistence.frozenColumns.enabled is true (default). Override the key with persistence.frozenColumns.storageKey.

CSS Custom Properties

Custom Property Description
--vg-frozen-bg Background fill of frozen cells (prevents see-through during horizontal scroll)
--vg-freeze-guide-color Color of the vertical freeze boundary line

Localization

messages: {
    freezeColumn: 'Freeze column',
    unfreezeColumn: 'Unfreeze column',
    unfreezeAll: 'Unfreeze all'
}

Multi-Column Sorting

Context Menu

Right-click a sortable column header to open the context menu. Select Sort ascending or Sort descending to sort by that column, or Clear sorting to remove just that column from the sort chain — each item disables itself when it no longer applies (e.g. Sort ascending is disabled once the column is already sorted ascending). These items only touch the clicked column's place in the sort chain, leaving any other column's sort untouched; they are omitted entirely for a column with sortable: false or when sorting.enabled is false.

// getSortState() return shape
{
    columnIndex: 1,       // Primary sort column index
    direction: 'asc',     // Primary sort direction
    column: { ... },      // Primary sort column object
    sortColumns: [        // Full multi-sort chain
        { columnIndex: 1, direction: 'asc',  column: { key: 'name', ... } },
        { columnIndex: 4, direction: 'desc', column: { key: 'weight', ... } }
    ]
}

For server-side / OData sorting:

const grid = new VanillaGrid({
    sorting: {
        serverSide: true,
        onSort: (_column, _direction, sortState) => {
            const orderBy = (sortState.sortColumns || [])
                .map(entry => `${entry.column.key} ${entry.direction}`)
                .join(', ');
            // Re-fetch with the new $orderby
            reloadWithOrderBy(orderBy);
        }
    }
});

Localization

messages: {
    sortAscending: 'Sort ascending',
    sortDescending: 'Sort descending',
    clearColumnSort: 'Clear sorting'
}

Row Selection

Modes

Mode Behavior
'noselection' No selection UI (default)
'single' One row at a time; clicking a selected row deselects it
'multiple' Any number of rows; header checkbox selects/deselects all
const grid = new VanillaGrid({
    selection: {
        mode: 'multiple',
        rowKeyField: 'id',          // Field used as stable key
        showCheckboxes: true
    }
});

Key Strategy (Precedence)

  1. rowKeyGetter(row) — custom function, takes priority
  2. Column marked with isKeyColumn: true in column definitions
  3. rowKeyField option (default 'id')
  4. Auto-generated UUID per row (fallback; a console warning is emitted)

Selection API

grid.getSelectedKeys();          // ['1', '42', ...]
grid.getSelectedRows();          // [{ id: 1, ... }, { id: 42, ... }]
grid.setSelectedKeys(['1', '2']); // Replace current selection
grid.clearSelection();
grid.isRowSelected(rowObject);   // true / false
grid.selectionState;             // 'none' | 'partial' | 'all'
grid.selectAllActive;            // true when sticky select-all is active

selectionChanged Event

Dispatched on the viewport element (bubbles):

viewport.addEventListener('selectionChanged', (e) => {
    const {
        mode,            // 'single' | 'multiple'
        selectionState,  // 'none' | 'partial' | 'all'
        selectAllActive, // boolean
        selectedKeys,    // string[]
        selectedCount,   // number
        selectedRows     // object[]
    } = e.detail;
});

Note: selectedKeys and selectedRows are lazy compute-once getters — the arrays are materialized on first access, so listeners that never read them cost nothing even with select-all active on large datasets. Read them within the event dispatch (as above) for values that match the change being reported.

rowDblClick Event

Dispatched on the viewport element (bubbles) when a row is double-clicked. Selection is not toggled by a double-click — the row keeps its current selected/unselected state.

Double-click detection uses pointerdown timing instead of the native dblclick event, ensuring reliable firing even when custom renderCell content (e.g. thermometer bars) replaces cell DOM between clicks.

viewport.addEventListener('rowDblClick', (e) => {
    const {
        row,       // object — the row data
        rowIndex   // number — index in displayRows
    } = e.detail;
});

On the <vn-grid> web component, this is bridged as vn-grid-row-dblclick:

document.querySelector('vn-grid').addEventListener('vn-grid-row-dblclick', (e) => {
    console.log('Double-clicked row:', e.detail.row);
});

Infinite Scroll + Select All

When all currently-loaded rows are selected (selectionState === 'all'), VanillaGrid sets a sticky select-all flag (selectAllActive). Any rows appended by subsequent infinite-scroll pages are automatically selected too.

Deselecting a single row in this state clears the sticky flag.

DataManager

The DataManager pattern decouples data fetching from grid rendering. Attach a DataManager to a <vn-grid> element with setDataManager().

Base DataManager API

Subclass DataManager and override any of these async methods:

Method Signature Description
fetchRows(context) async (ctx) => row[] Fetch the first page of rows
fetchMoreRows(skip, top, context) async (skip, top, ctx) => row[] Fetch a subsequent page
getPageSize() () => number Page size (default 1000)
buildRequestHeaders(context) (ctx) => object HTTP headers for default fetches
transformRows(rows, context) (rows, ctx) => rows Post-process rows before render
handleSort(column, direction, sortState) (col, dir, state) => void Update the sort clause; fire _fireConfigChanged() so a grid with setAutoReloadOnConfigChange(true) re-fetches page 0 on sort
onRowsLoaded(rows, context) (rows, ctx) => void Side-effect after rows load

Total row count is a push, not a method to override: call this._fireTotalRowCountChanged(count) from within fetchRows() (or wherever your subclass determines the total for its current query) — see Total Row Count.

ODataDataManager

A full-featured DataManager for OData endpoints.

const dm = new ODataDataManager({
    baseUrl: 'http://localhost:3000/api/items',
    countUrl: 'http://localhost:3000/api/items/$count',
    headers: { 'X-Custom-Header': 'value' },
    defaultOrderBy: 'name asc',
    defaultFilter: "active eq true",
    pageSize: 500,
    onSortChanged: (orderBy) => console.log('Sort:', orderBy),
    onRowsLoaded: (rows, ctx) => console.log('Loaded', rows.length),
    onFetchResponse: (response) => { /* inspect raw Response (headers, status) */ },
    onBeforeFetch: () => { /* e.g. clear a status line */ },
    onFetchError: (error) => console.error(error)
});

gridElement.setDataManager(dm);
gridElement.initializeGrid({
    infiniteScroll: { enabled: true },
    sorting: { serverSide: true },
    onTotalRowCountChanged: (count) => console.log('Total:', count)
});
gridElement.setAutoReloadOnConfigChange(true); // re-fetch page 0 on sort/filter/search
await gridElement.loadRowsAsync();

Server-side sort re-fetch: handleSort() updates $orderby and fires a config-change, so with setAutoReloadOnConfigChange(true) a sort re-fetches page 0 automatically — no onSortChanged reload callback needed (it is a notification-only hook). Without auto-reload, reload yourself (e.g. call loadRowsAsync() from onSortChanged).

Loading state is automatic: loadRowsAsync() brackets every load — the first one included — with setLoading(true/false) itself (error path included, guarded against superseded concurrent loads). Do not wire setLoading into onBeforeFetch/onFetchResponse/onFetchError; those are pure app hooks (status text, raw-Response inspection, error UI). onBeforeFetch/onFetchResponse fire once per user-visible load (fetchRows()), never per infinite-scroll load-more page (which has its own inline skeleton); onFetchError fires for load-more failures too.

Runtime OData methods:

dm.getBaseUrl() / dm.setBaseUrl(url)
dm.getCountUrl() / dm.setCountUrl(url)
dm.getOrderBy() / dm.setOrderBy(orderBy)
dm.getFilter() / dm.setFilter(filter)
dm.getSearchTerm() / dm.setSearchTerm(term)   // $search (default) or $filter contains()
dm.getSearchMode() / dm.setSearchMode(mode)   // 'search' | 'filter'
dm.getSearchFields() / dm.setSearchFields(fields)

Construct with searchMode: 'search' | 'filter' (default 'search') and, for 'filter' mode, searchFields: ['Name', 'City']. getSearchFields() returns null (unrestricted) unless searchMode: 'filter' is active and the list is non-empty — in 'search' mode the server owns $search's field scope, so the configured list is inert. See Generic Search.

GraphQLDataManager

A DataManager for GraphQL endpoints. GraphQL standardizes only transport and envelope (POST { query, variables }200 OK { data?, errors? } — a GraphQL error is an HTTP 200 with a populated errors array, not a 4xx/5xx), so this manager owns everything universal (transport, envelope/errors handling, cancellation, last-wins version guarding, total-count push, config-changed events, response unwrapping via a declared dot-path) while the host supplies the query document plus small builder hooks that translate grid state into the schema's variables. Two-tier config: declare variableNames for the common case, or pass buildVariables (which then fully owns assembly) plus the per-slice buildSort / buildFilter / buildSearch hooks. Supports both offset paging and Relay cursor paging (paginationStyle: 'offset' | 'cursor'), and a total from either an inline totalPath or a separate countQuery.

const dm = new GraphQLDataManager({
    endpoint: 'https://graphql.anilist.co',
    query: ANILIST_QUERY,
    paginationStyle: 'offset',
    pageSize: 50,
    infiniteScroll: true,
    dataPath: 'Page.media',                     // where the rows array lives in `data`
    totalPath: 'Page.pageInfo.total',           // inline total — no second request
    buildVariables: (state) => ({
        page: state.page, perPage: state.perPage,
        search: state.search, sort: state.orderBy, ...state.filter,
    }),
    buildSort: (column, direction) => [mapToMediaSort(column, direction)],
    buildFilter: (model) => mapToAniListArgs(model),
    onFetchError: (error) => console.error(error),
    onGraphQLErrors: (errors, data) => { /* default throws GraphQLDataManager.GraphQLError */ },
});

gridElement.setDataManager(dm);
gridElement.initializeGrid({
    infiniteScroll: { enabled: true },
    sorting: { serverSide: true },
    onTotalRowCountChanged: (count) => console.log('Total:', count)
});
await gridElement.loadRowsAsync();

Runtime methods: getEndpoint() / setEndpoint(url), getQuery() / setQuery(query), getSearchTerm() / setSearchTerm(term), getColumnFilters() / setColumnFilters(model), isInfiniteScroll() / setInfiniteScroll(enabled), getMaxRows() / setMaxRows(n).

A populated errors array throws GraphQLDataManager.GraphQLError (carrying .graphQLErrors) by default — even alongside partial data; a non-throwing onGraphQLErrors override tolerates partial data. In cursor mode the manager unwraps a Relay connection's edges[].node and reads pageInfo automatically. See GraphQLDataManager Internals and anilist-anime-js (AniList) for a working demo.

StaticDataManager

For pre-loaded in-memory datasets with client-side paging.

const dm = new StaticDataManager({
    rows: myDataArray,
    pageSize: 500
});

gridElement.setDataManager(dm);
gridElement.initializeGrid({ infiniteScroll: { enabled: true } });
await gridElement.loadRowsAsync();

Runtime methods: setRows(rows), getRows(), getPageSize(), setSearchTerm(term) / getSearchTerm(), setSearchFields(fields) / getSearchFields(), prewarmSearchIndex()

Pre-warming search (large datasets). On a large dataset the first search pays a one-time haystack extraction (later searches are instant). prewarmSearchIndex() builds that index ahead of time so the first search is a cache hit too. It's best-effort and idempotent, and nothing warms unless you ask it to.

The simplest way is to let the manager schedule it, via the prewarmSearchIndex option — right next to searchFields, which defines what the index covers:

const dm = new StaticDataManager({
    rows,
    searchFields: ['firstName', 'lastName', 'email'],
    prewarmSearchIndex: true        // default false
});

When on, the manager warms the index at idle after each row ingestion (construction with rows, and every setRows()), so the build lands after first paint and never competes with the initial render — the extraction is chunked and rAF-yielded, so running it during a render would steal frames from it. A bounded timeout means "at idle" can never become "never". Re-warming is cheap: the pre-warm resolves immediately while the index is resident, so only a genuine data or searchFields change costs anything.

Off by default — warming spends CPU and memory up front that is wasted on users who never search, so nothing happens unless you ask. It also only pays off at scale: below workerThreshold (50,000 rows) the build runs on the main thread and a first search over that many rows is fast anyway, so leaving it off is usually right for small datasets.

This is a StaticDataManager option, not a grid one — a server-backed manager searches remotely and has no local index to warm — so it also works for a manager used standalone, with no <vn-grid> attached. To control the timing yourself instead, leave it off and call the method directly:

gridEl.addEventListener('vn-grid-loaded', () => {
    requestIdleCallback(() => gridEl.getDataManager().prewarmSearchIndex());
});

Measuring the build (dev mode). Set window.VanillaGridDevMode = true — before the grid loads — and StaticDataManager emits console.info messages on the VanillaGrid: channel when it builds the search index:

VanillaGrid: search index build started — 500,000 rows × all fields (worker)
VanillaGrid: search index built in 5,182 ms — 500,000 rows × all fields (worker)

Each line names the row count, the searched fields and the path (worker or in-thread) — the three things that determine the cost — so you can confirm the pre-warm really starts early and see what scoping searchFields saves. A "started" line is only emitted when a build actually begins, never on an already-resident no-op, and a failed build reports its own outcome rather than leaving a dangling start line. The flag is off by default and there is no build-time dev/prod split, so production stays silent unless a host opts in. Only the pre-warm is instrumented — an index built lazily by the first search is not.

Search matches a case-insensitive substring against the raw field values. By default every scalar field is searched; pass searchFields: ['name', 'address.city'] (dotted paths allowed) to the constructor — or call setSearchFields(...) — to restrict the scope. Trigger from the element with await gridElement.search(term). getSearchFields() returns null when unrestricted — consumed by <vn-grid-toolbar-search> to show a fields-restricted tooltip, and by gridElement.getSearchFields().

Memory & performance on large datasets. For very large in-memory datasets (hundreds of thousands to millions of rows), two opt-in levers reduce client-side memory and first-search time without any cross-origin isolation:

const dm = new StaticDataManager({
    // Rows parsed from a server payload: every cell is its own string
    // instance, so the low-cardinality columns below dedup heavily.
    rows: await response.json(),
    searchFields: ['name', 'email', 'city'],
    internStringColumns: ['department', 'city', 'country', 'role'],
});

Viewport Row Snapping

When layout.snapViewportToRows is enabled (default), VanillaGrid snaps the viewport body height to the nearest lower multiple of the effective row height. This avoids partial-row viewport artifacts such as bottom-border misalignment and clipped-looking last rows.

Resize Scroll Stabilization

VanillaGrid includes guards to prevent resize-induced scroll feedback loops and accidental load cascades when the viewport height changes.

What this protects against:

Current behavior:

Related tuning knobs: layout.rowHeight, layout.bufferRows, layout.snapViewportToRows.

Localization Hooks

VanillaGrid stays locale-agnostic by default and exposes formatting hooks so the host app can provide localization logic:

Example:

const grid = new VanillaGrid({
    formatting: {
        locale: 'it-IT',
        messages: {
            of: 'di',
            hideColumn: 'Nascondi colonna',
            showAllColumns: 'Mostra tutte le colonne',
            freezeColumn: 'Blocca colonna',
            unfreezeColumn: 'Sblocca colonna',
            unfreezeAll: 'Sblocca tutto',
            sortAscending: 'Ordina crescente',
            sortDescending: 'Ordina decrescente',
            clearColumnSort: 'Rimuovi ordinamento'
        },
        formatInteger: (value) => new Intl.NumberFormat('it-IT').format(value),
        formatScrollIndicator: ({ firstRow, lastRow, displayedRowCount, totalRowCount, formatInteger, messages }) => {
            const denominator = Number.isFinite(totalRowCount) ? totalRowCount : displayedRowCount;
            return `${formatInteger(firstRow)} - ${formatInteger(lastRow)} ${messages.of} ${formatInteger(denominator)}`;
        }
    },
    infiniteScroll: {
        enabled: true
    },
    onTotalRowCountChanged: (count) => console.log('Known total:', count)
});

Keyboard Navigation

When the viewport is focused or hovered, the following keys scroll the grid:

Key Action
ArrowUp / ArrowDown Scroll one row
PageUp / PageDown Scroll one viewport height
Space / Shift+Space Scroll down/up one viewport height
Home / End Jump to first/last row

Themes

The component includes eight pre-built themes in themes/:

File Description
themes/vn-grid-default.css Clean, minimal theme
themes/vn-grid-material.css Google Material
themes/vn-grid-fiori.css SAP Fiori-inspired theme
themes/vn-grid-carbon.css IBM Carbon Design System (light)
themes/vn-grid-carbon-dark.css IBM Carbon Design System (dark)
themes/vn-grid-glow.css Glow UI (light)
themes/vn-grid-glow-dark.css Glow UI (dark)
themes/vn-grid-fluent.css Microsoft Fluent 2
themes/TEMPLATE-vn-grid-theme.css Blank template for custom themes

Switching Themes

Via the web component (it swaps the injected theme stylesheet itself):

gridElement.setTheme('carbon-dark');

Creating Custom Themes

Copy themes/TEMPLATE-vn-grid-theme.css, override the CSS custom properties listed in the template, and register it with VanillaGridElement.registerTheme('mytheme', 'path/to/vn-grid-mytheme.css').

Sort Icons (required per-theme tokens)

The sort glyphs are theme tokens with no base default — every theme declares its own full set on .vn-grid-header-table th (a theme that omits them renders no sort glyphs at all):

.vn-grid-header-table th {
    --vn-grid-sort-icon-asc: '▲';               /* ascending glyph */
    --vn-grid-sort-icon-desc: '▼';              /* descending glyph */
    --vn-grid-sort-icon-sortable: none;         /* unsorted "sortable" affordance; none = off */
    --vn-grid-sort-icon-sortable-opacity: 0;    /* affordance resting opacity */
    --vn-grid-sort-icon-sortable-hover-opacity: 0; /* …while the header is hovered */
}

The affordance supports three modes per theme: off (none), hover-only (opacity 01 — the Carbon themes show a hover-only with / direction glyphs; the Material theme shows an alphabetical "AZ" SVG glyph via content: url("data:image/svg+xml,…") — both triangles on hover, a single accent-colored triangle when sorted asc/desc, colors baked into the SVGs since url() images ignore currentColor), or always visible (resting opacity > 0). See 15-themes-implementation.md §4.5.1 for the full matrix and the migration note for pre-existing custom themes.

Header parts

The header label is one row: .vn-grid-header-text (with its sort indicator), then .vn-grid-header-secondary-text (e.g. [kg]), then, on an aggregated column, .vn-grid-aggregate-badge. In a narrow column the label ellipsizes first, then the secondary text; the badge never shrinks. The badge reads five optional tokens, each with a fallback:

.vn-grid-table-container {
    --vn-grid-aggregate-badge-color: inherit;
    --vn-grid-aggregate-badge-bg: color-mix(in srgb, var(--vn-grid-filter-accent) 16%, transparent);
    --vn-grid-aggregate-badge-radius: 999px;
    --vn-grid-aggregate-badge-padding-x: 5px;
    --vn-grid-aggregate-badge-font-size: 0.8em;
}

The badge and each group footer cell show the function as an icon: a masked SVG painted in the text colour, one token per built-in function. The defaults live in vanilla-grid.css; a theme overrides any of them with a 24×24, round-capped, stroke-width='2.5' SVG:

.vn-grid-table-container {
    --vn-grid-aggregate-icon-sum: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M18 4H6l7 8-7 8h12'/></svg>");
    /* also -avg, -median, -min, -max, -countTrue, -countDistinct */
    --vn-grid-aggregate-icon-size: 1em;
}

See 15-themes-implementation.md §4.8.

Implementation Docs

For implementation-level details, see the technical docs. They are listed in the same reading order as 00-index.md's Documents section — foundations first, then the features that build on them.

Core engine

Data access

Sorting, filtering, and column types

Row interaction

Column management

Presentation

Export

Persistence, delivery, and performance

Row grouping

Browser Support

Performance

License

MIT

Contributing

Contributions are welcome! Please ensure:

Changelog

Version 1.40.0

Version 1.39.1

Version 1.39.0

Version 1.38.0

Version 1.37.0

Version 1.36.1

Version 1.36.0

Version 1.35.2

Version 1.35.1

Version 1.35.0

Version 1.34.0

Version 1.33.3

Version 1.33.2

Version 1.33.1

Version 1.33.0

Version 1.32.1

Version 1.32.0

Version 1.31.1

Version 1.31.0

Version 1.30.3

Version 1.30.2

Version 1.30.1

Version 1.30.0

Version 1.29.1

Version 1.29.0

Version 1.28.0

Version 1.27.0

Version 1.26.0

Version 1.25.0

Version 1.24.3

Version 1.24.2

Version 1.24.1

Version 1.24.0

Version 1.23.0

Version 1.22.0

Version 1.21.0

Version 1.20.0

Version 1.19.3

Version 1.19.2

Version 1.19.1

Version 1.19.0

Version 1.18.0

Version 1.17.0

Version 1.16.0

Version 1.15.2

Version 1.15.1

Version 1.15.0

Version 1.14.0

Version 1.13.0

Version 1.12.0

Version 1.11.0

Version 1.10.2

Version 1.10.1

Version 1.10.0

Version 1.9.0

Version 1.8.0

Version 1.7.0

Version 1.6.0

Version 1.5.0

Version 1.4.3

Version 1.4.2

Version 1.4.1

Version 1.4.0

Version 1.3.0

Version 1.2.0

Version 1.1.0

Version 1.0.0