TOCLOCO Inc

Lattice Grid

A data grid for people who have been let down by data grids. A hundred thousand rows that scroll like ten. Two files, no build step, no dependencies. This guide explains what each part of the API does and why it works the way it does; the reference tables are the shorter version for when you already know.

Version 1.65.0 Zero dependencies No build step

Translating the grid

Every string the grid renders or announces comes from a message catalogue. British English is the default; supply messages to replace some or all of it.

import { createGrid, FR_FR } from '@toclocoinc/lattice-grid';

createGrid(element, { locale: 'fr-FR', messages: FR_FR });

Where locale is not set, the grid takes the language the page declares in its lang attribute. Overrides merge over the default, so translating part of the interface leaves the remainder in English rather than showing raw keys, and a key that is not in the catalogue is ignored with a warning.

Catalogues ship for twenty-one locales: British and American English, French (France and Canada), Italian, Spanish, Brazilian Portuguese, German, Dutch, Swedish, Danish, Norwegian, Finnish, Polish, Czech, Hungarian, Romanian, Ukrainian, Greek, Japanese and Arabic. Each is an export of the package, so importing one does not reduce what is bundled. EN_US is a partial overlay carrying only what differs from British English and merging over it. AR_SA is an alias for AR rather than a separate catalogue: Arabic ships pan-Arabic, and a region appears in a name only where two variants exist. For anything else, resolveCatalogue(tag) resolves a tag to a catalogue and falls back to the base language, so ar-EG and es-MX both find one. FR_CA is a complete catalogue that follows Quebec usage where it differs, a column there is figée rather than épinglée.

None of the translations has been reviewed by a native speaker. They are structurally complete and checked for placeholder integrity and plural coverage, but they are a starting point for review rather than finished copy.

Writing your own

A message is a string, or an object keyed by plural category when it counts something:

{
  'menu.sortAscending': 'Sortera stigande',
  'count.rows': { one: '{count} rad', other: '{count} rader' },
}

Placeholders are named, so word order is yours to choose. The parameter called count selects the plural form, using the categories the language actually has: English needs two, French treats zero as singular, Arabic has six. Numbers are formatted for the locale automatically; do not format them yourself.

MESSAGE_KEYS lists every key. auditCatalogue(yours) returns what is missing and what is not a real key, which is the quickest way to check a translation before shipping it.

Keys seeded in English, awaiting translation

A key added after a catalogue was written ships in British English only until a translator supplies it; Messages merges every catalogue over the default, so the grid says it in English rather than showing the key. The shipped catalogues do not carry an English copy under the guise of a translation, and the build's completeness test lists exactly which keys are in this state. As of 1.55 the most recent additions are the strings that had shipped as template literals, untranslated in every locale (BACKLOG-0001106): the presence live region and roster note, a11y.presence.refused, presence.someoneElse and presence.hidden; the comment panel's changed-since note, comments.valueMoved; the facet band's accessible name, facets.filter; the heading tooltip that names a reduction, header.totalOf; the audit-mode tooltip, diff.before and diff.empty; the kanban list names, kanban.columnCards and kanban.laneCards, both plural objects selected by count; and the gantt lag label, gantt.lag and gantt.lead. Supply any of them in messages to translate it today.

The kanban board and the gantt view are modules and do not import the catalogue. A board bound to a grid, or a plan created with one, borrows that grid's messages; otherwise pass messages — a grid's own, or any object with t(key, params) — to createKanban or to gantt.mount. With neither they render the English.

The grid is checked in the other direction too. auditCatalogue tells you a catalogue is complete: that a translator covered every key. It cannot tell you the grid only ever renders text that came from a catalogue in the first place, and a string written into the source passes every test, because the tests assert on the English the grid happens to produce.

So the build refuses one. Any literal reaching an element's text, or an announced attribute such as aria-label, title or placeholder, has to come from the catalogue. That is what stops a localised grid drifting back into English one plausible change at a time.

Right-to-left

The grid lays out right to left. Set direction: 'rtl' outright, or leave it unset and it follows the element's computed dir first and the locale second, so locale: 'ar' renders right to left with no further configuration, and a grid inside a page that has already declared dir="rtl" agrees with it.

createGrid(element, { locale: 'ar' });        // direction follows the locale
createGrid(element, { direction: 'rtl' });    // or say so outright

The element comes before the locale because a page that has set dir has already made a decision about layout, and a grid inside it should not disagree on the strength of a language tag.

Pinned columns swap sides, the header and pinned rows follow the scroll the other way, and column resize and reorder, the fill handle, annotations, the facet band, the comment marker and menu placement all follow the writing direction rather than the physical one.

Not yet exercised with bidirectional text. The layout is verified, but only with Latin text in a right-to-left grid. Cells, headings and editors holding actual Arabic or Hebrew (particularly mixed with Latin text or numbers) have not been tested.

What it is

Lattice Grid renders tabular data in a browser. That sentence covers a great many products, so here is what is actually different about this one.

It holds data in columns, not rows

A grid of a hundred thousand rows and thirty columns is three million values. Held as row objects, that is three million property lookups per pass and a great deal of memory the garbage collector has opinions about. Lattice stores each column as one typed array: numbers in a Float64Array, repeated strings as integers into a dictionary, booleans as bits in a bitset.

Why it matters to you: sorting a column is a sort over one contiguous array of numbers, not a walk over a hundred thousand objects. Filtering produces a bitmask rather than a new array of rows. Both stay fast at sizes where the row-object approach has already given up, and neither allocates much, so the browser is not collecting garbage while the user is scrolling.

Work is memoised in stages

Everything between your data and the screen is six stages: filter, sort, group, total, pivot, flatten. Each remembers its result and the inputs it was computed from.

Change a sort and the filter stage is not recomputed: its inputs did not change. Edit a cell in a column nobody sorts, filters or groups on and none of the first five run; only the totals move. This is why a grid that is heavily filtered and grouped still feels immediate when you type in a cell.

The renderer reuses everything

Rows and cells come from pools. Scrolling reassigns the twenty or so row elements that exist rather than creating and destroying thousands, and the only vertical write is a transform, which the compositor handles without a layout pass.

No dependencies, and no build step

Not "few dependencies": none, at runtime and at build time. No lodash, no date library, no virtualisation library, no icon font. The bundler and minifier that produce the distribution are part of the repository. You can drop two files into a page and be finished.

A large share of enterprise line-of-business frontends are built without a bundler, and they are usually treated as second-class by grid vendors. Here the script tag is the first example in the documentation, not an appendix.

Install

Two files. Nothing is fetched at runtime (no CDN, no font, no sprite sheet) however the two files themselves got onto the page. Four equally valid ways to get them there:

npm

npm install @toclocoinc/lattice-grid
import { createGrid } from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
const grid = createGrid(document.getElementById('grid'), config);

jsDelivr, no npm install, no bundler

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/lattice-grid.min.js"></script>

<script>
  const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>

Script tag, your own build

<link rel="stylesheet" href="lattice-grid.min.css">
<script src="lattice-grid.min.js"></script>

<script>
  const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>

ES modules, your own build

import { createGrid } from './lattice-grid.esm.min.js';
const grid = createGrid(document.getElementById('grid'), config);

jsDelivr mirrors every version published to npm at cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/<file>. @1 pins the major: a page in production picks up fixes within 1.x and never a breaking release, where @latest would. To freeze a page on one exact build, replace @1 with the full version getVersion() reports. The same convention reaches a module: .../modules/htmx.esm.min.js, .../modules/dhtmlx-compat.esm.min.js, and so on. Type declarations resolve automatically through npm's own types field; for editor tooling against the CDN or a plain script tag, point your tsconfig at lattice-grid.d.ts directly.

Everything else in the distribution is an alternative packaging or a development aid:

FileGzippedWhat it is
lattice-grid.min.jsThe whole product as a UMD build. Defines window.LatticeGrid, and works with AMD and CommonJS loaders.
lattice-grid.min.cssThe single stylesheet. Without it the grid is in the DOM and unreadable, no widths, no scrolling, no theme.
lattice-grid.esm.min.jsThe same, as an ES module.
lattice-core.esm.jsHeadless core for Node, no renderer. See server-side export.
lattice-grid.d.tsType declarations.

Your first grid

Three things are required: an element to mount into, some columns, and some rows. Everything else has a working default.

The whole thing

const grid = LatticeGrid.createGrid(document.getElementById('grid'), {
  rowKey: 'id',
  columns: [
    { field: 'circuitId', title: 'Circuit' },
    { field: 'region' },
    { field: 'monthlyCharge', type: 'number', format: 'currency:GBP:2', total: 'sum' },
    { field: 'installedOn', type: 'date', format: 'date:dd MMM yyyy' },
  ],
  rows: data,
});

A few things happened there without being asked for. region got a title of "Region", a field name is turned into a readable heading rather than left as-is. The number column right-aligned itself, because numbers align right and a grid should not need telling. The date column parsed 2024-03-11 and rendered 11 Mar 2024. And total: 'sum' put a figure in the totals row.

On rowKey: it names the field that identifies a row. Set it if you have one. Without it the grid assigns keys per row object, which is enough for sorting, filtering, selection and copying within a session, but not across a reload, because new objects are new rows. Change tracking, streaming dedupe, selection persistence and remote reload all want a real key, and the grid warns once, naming them.

React, Vue, Svelte

One optional bundle per framework. They are thin: the grid is created once against a host element, prop changes are pushed into it through the same public API you would call by hand, and it is destroyed on unmount.

You pass the framework in. Every adapter is a factory taking the framework and createGrid, rather than importing either. Lattice ships zero dependencies and the bundler rejects bare specifiers outright, so an adapter could not import React from 'react' even if it wanted to. The same choice keeps each bundle small: the bundler inlines whatever it can resolve, so an adapter that imported the grid would carry a second copy of it. Passing both in leaves each adapter a few kilobytes of glue, and means the adapter cannot disagree with the grid version you already loaded.

React

import React from 'react';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/react';

const LatticeGrid = createLatticeGrid({ React, createGrid });

function Circuits({ rows }) {
  const ref = React.useRef(null);
  return (
    <LatticeGrid
      ref={ref}
      className="grid"
      columns={columns}
      rows={rows}
      rowKey="id"
      sort={[{ col: 'name', dir: 'asc' }]}
      onCellChanged={(e) => save(e.key, e.colId, e.value)}
    />
  );
}

// ref.current.grid is the live grid, for anything without a prop.

Hold your props steady. Change detection is reference equality, because deep-comparing a million-row array on every render would cost more than the reload it avoids. Build columns once outside the component, or memoise it; a fresh array literal on each render tells the grid the columns changed and it will rebuild them. Rows are the same: hand back a new array when the data actually changes, not before.

StrictMode is handled. React 18 deliberately mounts, unmounts and mounts again in development; the effect cleanup destroys the first grid, so the second starts clean and nothing leaks.

React: every viewer, not only the grid (1.63)

import React from 'react';
import ReactDOM from 'react-dom';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import { createTabs } from '@toclocoinc/lattice-grid/modules/tabs';
import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
import { createLatticeReact } from '@toclocoinc/lattice-grid/modules/react';

// createLatticeReact builds whichever components the factories you pass support.
// The individual factories are there too, if you want only one:
// createLatticeGrid, createLatticeKPI, createLatticeChart, createLatticeKanban,
// createLatticeGantt, createLatticeLayout, createLatticeTabs,
// createLatticeGridContext, createLatticeRouter, createLatticeViewer.
const L = createLatticeReact({
  React, ReactDOM, createGrid, createKPI, createChart, createTabs, createDataRouter,
});

function Dashboard({ rows, updates }) {
  const router = L.useLatticeRouter(ROUTER_CONFIG);
  const [tab, setTab] = React.useState('all');
  return (
    <L.LatticeRouterProvider router={router}>
      <L.LatticeGridProvider>
        <L.LatticeTabs
          active={tab}
          onTabChanged={(e) => setTab(e.id)}
          tabs={[
            { id: 'all', label: 'All',
              content: <L.LatticeGrid name="all" route="all" {...CONFIG} rowUpdates={updates} /> },
            { id: 'big', label: 'Significant',
              content: () => <L.LatticeGrid name="big" route="significant" {...CONFIG} /> },
          ]}
        />
        <L.LatticeKPI gridName="all" tiles={TILES} columns={5} />
        <L.LatticeChart gridName="all" type="bar" x="region" y="count" onClick={pick} />
      </L.LatticeGridProvider>
    </L.LatticeRouterProvider>
  );
}

Every viewer is a component now. Before 1.63 the adapter wrapped createGrid and nothing else, so a React application wrote its own useEffect for the KPI panel, for each chart, for the board, for the Gantt — and its own tab strip, because the tabs module builds its tabs' grids itself and a grid built that way is not a component. createLatticeKPI, createLatticeChart, createLatticeKanban, createLatticeGantt, createLatticeLayout and createLatticeTabs each return one component; createLatticeViewer is the generic behind them, for anything not yet named, and createViewerController is the framework-free lifecycle underneath.

A viewer finds its grid through context, not a ref. A KPI panel and a chart are built against a grid instance, and that instance does not exist until after the first render. Writing to a ref re-renders nobody, so a sibling never learns the grid arrived. createLatticeGridContext returns { LatticeGridProvider, useLatticeGrid }: a grid publishes itself under its name, a viewer takes it by gridName, and the viewer is built the moment it appears. A page with one grid names nothing.

A tab's content is yours. createLatticeTabs keeps the module's tablist semantics, keyboard handling, lazy first mount and live badges, and renders each tab's React content into the module's own panel element through a portal — so a tab's grid is a real <LatticeGrid> with props, a ref and the surrounding context. A tab with no content is left to the module, so both kinds mix on one strip.

The router is owned by a hook. createLatticeRouter returns useLatticeRouter, which creates the router in an effect and destroys it in that effect's cleanup — never in a useState initialiser, which React calls more than once by design and whose discarded copy is never destroyed. It returns null on the first render and the router on the second. <LatticeGrid route="…"> attaches to whichever router LatticeRouterProvider published, and detaches before the grid is destroyed so the router never holds a dead grid.

A live feed needs no ref. rowUpdates is a keyed diff handed to grid.rows.apply() whenever the object's identity changes. predicates maps { name: fn } to grid.filters.where(name, fn), which composes with whatever filter the reader set — unlike the filters prop, which replaces the whole tree.

What is a live prop is declared, not guessed. The grid takes any changed configuration key through one call; no other viewer does. So each viewer lists the props it can take while running (VIEWER_APPLY) and everything else is mount-time configuration. A mount-time prop that changes is neither ignored nor silently remounted: it is named once, with the remedy — a key that changes when you want the rebuild, or the ref. VIEWER_EVENTS and viewerHandlerName are the matching event surface (card:move becomes onCardMove), and DEFAULT_GRID_NAME is the name a grid publishes under when you choose none.

React 18 and 19, client only. Nothing uses an API added in 19 or removed in 19. Every component owns a real DOM element, so there is no SSR and no React Server Component support.

Vue 3

import * as vue from 'vue';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/vue';

const LatticeGrid = createLatticeGrid({ vue, createGrid });
<LatticeGrid
  :columns="columns"
  :rows="rows"
  row-key="id"
  :sort="[{ col: 'name', dir: 'asc' }]"
  @cell-changed="onCellChanged"
  @selection-changed="onSelectionChanged"
/>

Events are re-emitted under dashed names: cell:changed becomes @cell-changed, because a colon in a Vue template is directive syntax and cannot be bound. Every event in the table below is declared in emits.

Svelte

<script>
  import { createGrid } from '@toclocoinc/lattice-grid';
  import { createLatticeAction } from '@toclocoinc/lattice-grid/modules/svelte';

  const lattice = createLatticeAction({ createGrid });
  let rows = [];
  let grid;
</script>

<div
  use:lattice={{ columns, rows, rowKey: 'id', onGrid: (g) => (grid = g) }}
  on:ready={(e) => (grid = e.detail.grid)}
  on:cell-changed={(e) => save(e.detail)}
></div>

An action, not a component. Svelte's action contract is { update, destroy } over a node the caller already owns, which is exactly the shape of the work: create, push changes, tear down. A component wrapper would add an element and a props layer to arrive back at the same three calls. It is also the only adapter here that needs nothing but createGrid, since an action is a plain function with no framework runtime behind it. Grid events arrive as CustomEvents on the node, with the grid event as detail. If you want a component, four lines around this gets you one.

Reaching the grid. The action's return value is Svelte's { update, destroy } and cannot carry the instance, so — at parity with React's ref.current.grid and Vue's expose({ grid }) — there are two documented routes to the same object the vanilla examples use, for charts, statistics and imperative pivot. Pass an onGrid callback in the action params and it is called once with the live grid the moment it is created (synchronously, before the first render); it is consumed by the adapter and never reaches the grid config. Or read it off the ready event, whose detail carries { grid } alongside the event's own fields — ready fires a turn later, so use onGrid when you need it up front.

Accessing the grid instance from your framework

Anything the adapters do not surface as a prop — grid.export, grid.selection, grid.state and the rest — is reached on the live grid instance directly. It is the same Grid object createGrid returns; the adapter only builds and drives it. There is one documented way to reach it per adapter, and it is null until the grid is mounted.

AdapterHow you reach the grid
React A forwarded ref: ref.current.grid.
Vue 3 A template ref calling the exposed method: this.$refs.grid.grid() (the component exposes a grid() getter).
Svelte Either an onGrid callback in the action params, called with the live grid the moment it is created: use:lattice={{ ...config, onGrid: (g) => grid = g }}; or, a turn later, the grid on any event's detail — the first ready event hands it over: on:ready={(e) => grid = e.detail.grid}.
Web Component A property on the element: el.grid.

React

const ref = React.useRef(null);
// after mount:
ref.current.grid.export.toCsv();

Vue 3

<LatticeGrid ref="grid" :columns="columns" :rows="rows" />

// in a method, after mount:
this.$refs.grid.grid().export.toCsv();

Svelte

<script>
  let grid;
</script>

<div
  use:lattice={{ columns, rows, rowKey: 'id' }}
  on:ready={(e) => grid = e.detail.grid}
></div>

<button on:click={() => grid?.export.toCsv()}>Export</button>

Web Component

const el = document.querySelector('lattice-grid');
el.grid.export.toCsv();

What the adapters do not do. They add no features and wrap no API: the grid instance is the same object the vanilla examples use, and anything without a prop is reached through it directly — via the ref in React, expose in Vue, or onGrid / the ready event in Svelte, as the table above shows. Nothing is proxied, so nothing can lag behind the grid.

The web component is self-contained: use it or the API, not both

The custom element carries the grid inside it, rather than being handed one. That is the point of the format: you add the element and it works, with nothing to wire up.

Do not load it alongside createGrid in the same page. You would get two independent copies of the grid, and the cost is not the download, it is that each copy keeps its own registries. A renderer, editor, data type or variant registered through one is invisible to the other, and a licence key validated in one is not validated in the other. Nothing errors; the custom renderer you registered simply never appears.

Pick one route per application. The other three adapters take createGrid as an argument and so share whatever copy you already loaded, and can be mixed with direct API use freely.

Coming from dhtmlx Grid

lattice-grid/modules/dhtmlx-compat exposes a Grid class shaped like dhtmlx's own dhx.Grid, the same constructor call, the same .data, .selection, .history, .export and .events namespaces: sitting on top of a real Lattice grid underneath. Swap the import and, for the surface below, the calling code does not change.

The wrapper shares the core the page already loads rather than carrying its own, so load @toclocoinc/lattice-grid alongside it: a bundler wires the shared import automatically (it dedupes against the core your app already imports), and a plain <script src> page loads the global build first. The pay-off is that a licence set on that one core applies to these grids too, and the module is a few kilobytes of translation rather than a second copy of the grid.

A drop-in constructor

import { Grid } from '@toclocoinc/lattice-grid/modules/dhtmlx-compat';

const grid = new Grid(container, {
  columns: [
    { id: 'name', header: [{ text: 'Name' }], width: 200, sortable: true },
    { id: 'qty', header: [{ text: 'Qty' }], type: 'number', editable: true, editorType: 'input' },
  ],
  data: rows,
});

grid.events.on('cellClick', (row, column, event) => {
  // row and column carry .id, row's own fields sit alongside it,
  // the same shape dhtmlx's own IRow/ICol declare.
});

What is covered: column definitions (header, width, sortable, resizable, hidden, editorType, editorConfig, options, template, summary); .data's add/update/remove/ removeAll/parse/load/find/ findAll/exists/getItem/getId/ getIndex/getLength/forEach/serialize/ sort/filter/resetFilter; .selection's setCell/getCell/getCells/isSelectedCell/ removeCell; .history's undo/redo/ canUndo/canRedo/clear/getHistory; .export.csv/.xlsx; and the events cellClick, cellDblClick, cellRightClick, afterEditStart, afterEditEnd, afterSort, filterChange, afterColumnDrop, resize, afterResizeEnd, afterColumnHide, afterColumnShow, afterExpand, afterCollapse, afterSelect, afterUnSelect, afterCopy, afterRowDrop and scroll. Grid-level dragItem: 'row' becomes rowReorder: true: same-grid drag-to-reorder.

cellClick, cellDblClick, cellRightClick, afterEditStart, afterEditEnd and afterSort call your handler with dhtmlx's own positional arguments: (row, column, event), not Lattice's own event object, because that is dhtmlx's own documented signature for them. afterRowDrop calls your handler with (data, event), firing from either a same-grid reorder settling or a row landing here from another grid, dhtmlx has one event name for what Lattice models as two. Every other mapped event calls your handler with Lattice's own event object, under Lattice's own field names, since a wrong guess at a fabricated positional shape is worse than an honest one.

Index means current display order: after sort, filter and grouping: everywhere .data takes or returns one. dhtmlx is not fully consistent about this across its own methods; this is the one meaning, held everywhere.

Cross-grid dragging needs an explicit rowTransfer. dhtmlx lets any two grids with dragItem: 'row' on the same page exchange rows by default; Lattice's rowTransfer is deliberately opt-in per pair, with no dhtmlx property to derive it from, so a caller wanting that behaviour passes rowTransfer straight through as a bonus config key.

What is not. Every before*/can*/cancel* event is unmapped: dhtmlx's convention lets a handler return false to cancel the action, and Lattice has no cancelable-event model to honour that with: approximating one would silently ignore a return false a caller depends on. Row and column drag-negotiation events (beforeRowDrag, dragRowOut, canRowDrop, cancelRowDrop, beforeRowDrop and their column equivalents) are unmapped for the same reason: each can refuse or steer a drag mid-gesture, which Lattice has no live protocol to offer. export.pdf() and export.png() throw: there is no raster export to translate to. .rangeSelection is offered on a best-effort basis and its range shape is this wrapper's own design, not dhtmlx's, a genuine RangeSelection module is a separate part of dhtmlx's own product, and this wrapper does not know its exact shape. Classic dhtmlXGridObject (pre-Suite 5, string-configured, index-addressed) is a different product in every respect that matters here and is not covered at all.

Calling an unmapped event name does not fail silently: the first subscription logs which name has no Lattice equivalent, so a caller relying on it finds out in development rather than in production.

Using with htmx

lattice-grid/modules/htmx lets a grid survive htmx's own DOM swaps, hydrate from a server-rendered <table>, and drive sort, filter and infinite scroll over plain htmx requests, the server owns pagination and the request lifecycle; this module only wires the grid's own state to it. Importing it is enough for the lifecycle half: it registers itself against document on load.

Declarative init

<script src="https://unpkg.com/htmx.org@2"></script>
<script type="module" src="dist/modules/htmx.esm.min.js"></script>

<div id="grid" data-lattice-grid></div>
<script type="application/json" data-lattice-config>
  { "columns": [{ "field": "name" }, { "field": "qty", "type": "number" }] }
</script>

// Anywhere on the page, once:
import { autoInit } from '@toclocoinc/lattice-grid/modules/htmx';
autoInit(document);

One file, not two. autoInit comes from modules/htmx itself here, not from the base package: this module already carries a complete, independently-bundled copy of createGrid and everything it depends on, the same way every module built this way does (a bundle inlines what it imports; it has no way to reach across to a copy some other <script> tag happens to have loaded). Left unexported, a page using htmx integration would load that engine twice: once for modules/htmx, again for the base package's own createGrid. This module re-exports createGrid, autoInit, hydrateTable, readTable, serialiseState and restoreState precisely so a page never has to choose between the two, it is the complete package for anything touching htmx, not an add-on alongside the base one.

Surviving a swap. autoInit(root) builds a grid on every [data-lattice-grid] element under root it has not already built one for: idempotent, so calling it again after a swap only picks up what is new. A sibling <script type="application/json" data-lattice-config> supplies columns and options; without one, a <table> element is hydrated instead, reading its header row for columns and its body rows for data, then replacing itself with the grid. Once imported, this module listens for htmx's own htmx:beforeCleanupElement and htmx:load and calls grid.destroy() / autoInit at the right moments automatically, a grid inside a swapped-out subtree is torn down before htmx detaches it; a grid inside newly-loaded content is built without re-scanning the whole page. A page with JavaScript disabled sees the plain <table>, still readable, since it is only ever replaced once the grid has actually mounted.

Finding a live grid from its element works everywhere in this library, not just through autoInit: element.__lattice holds the instance for any element createGrid was called on, and is cleared when the grid is destroyed.

Server-driven sort, filter and infinite scroll

import { createGrid, driveServerMode, driveInfiniteScroll } from '@toclocoinc/lattice-grid/modules/htmx';

const grid = createGrid(host, { columns, rows: [], rowKey: 'id' });
const COLUMNS = columns.map(c => ({ field: c.field }));

// Replaces the view outright: fires whenever sort or filter changes.
driveServerMode(grid, document.getElementById('query-trigger'), { columns: COLUMNS });

// Appends the next chunk: fires as the grid's own visible rows near the end.
driveInfiniteScroll(grid, document.getElementById('sentinel'), { columns: COLUMNS });
<div id="query-trigger" hx-get="/rows" hx-trigger="lattice:query-changed" hx-swap="none" hidden></div>
<div id="sentinel" hx-get="/rows" hx-trigger="revealed, lattice:scroll-near-end" hx-swap="none" hidden></div>

Two triggers, two elements, deliberately. A sort or filter change and a scroll asking for more rows are different operations: one replaces every loaded row, the other appends to them, and there is no way to tell the two apart once a response has landed if they share a trigger. Each function configures the request (offset, limit, sort, filters, a small, stable convention any server-side language can read with a JSON parser and a slice) and reads the response back into the grid itself, so hx-swap="none" is required on both: htmx sends the request and nothing else, since a grid is not an HTML swap target.

The sentinel's own trigger names two events for a reason. revealed is htmx's own once-per-element mechanism and gets the very first chunk, firing the moment htmx has processed the element: reliable because it needs nothing from this module's own timing. Every chunk after that fires through lattice:scroll-near-end, which driveInfiniteScroll dispatches once the grid's own visible row window comes within opts.threshold rows (default 20) of what is loaded. That split matters for a fixed-height, virtualised grid specifically: nothing about it ever leaves the page's own viewport once first revealed: new rows land inside the grid's own scroll area, not the page's, so a page-scroll-only trigger would fire exactly once and then go silent. Reading the grid's own render position instead is what makes every later chunk fire on genuine scroll, not on the repaint a successful load causes by itself.

Out-of-band updates. driveOobUpdates(grid, opts) watches for htmx's own out-of-band swaps landing on an element carrying data-lattice-row="<key>", reads the swapped fragment as that row's cells, and applies it to the grid in place: scroll position, selection and filter state are untouched, since nothing about the view is reloaded.

Browser history. On htmx:beforeHistorySave, every live grid's state (serialiseState: sort, filters, column order and widths, scroll position and selection, base64url-encoded and diffed against defaults so an untouched grid costs almost nothing) is written onto its element as data-lattice-state, which rides along in htmx's own history snapshot. On htmx:historyRestore, it is read back and applied: browser back returns a visitor to the sort, filter and scroll position they had, not a blank slate. A cache miss (the page was re-fetched from the server) restores nothing, since a fresh response is already the truth.

A failed request leaves the grid alone. driveServerMode and driveInfiniteScroll both listen for htmx:responseError/htmx:sendError; the grid's rows are never touched by a failed request, and a recoverable message is shown through grid.overlay rather than the grid going blank.

What this does not do. It does not call fetch or htmx.ajax() anywhere: htmx owns every request end to end; this only supplies the moment and the parameters, and reads the response back in. It does not import htmx: every htmx-specific call goes through globalThis.htmx, read at call time, so loading this module never requires htmx to already be on the page, only to be present by the time a driven request actually fires. It ships as ESM and as a plain <script src> build with no bundler required, with zero runtime dependencies beyond the grid itself and, at call time, htmx, but because it references the grid's own internals directly rather than the copy already on the page, the bundle carries a full copy of the grid core alongside its own code, the same trade-off the web component and dhtmlx wrappers already make.

How it works

Four ideas explain most of the API. If you read nothing else, read this section, the rest of the guide assumes it.

The row you see is not the row you supplied

Your data objects are held by reference and never copied. What the grid hands back is a row wrapper: your object under data, plus the identity and position the grid needs: key, index, level, whether it is a group row, whether it is expanded.

Reading a value

grid.rows.get(0).data       // your object, untouched
grid.rows.value('r1', 'cap') // the raw value
grid.rows.text('r1', 'cap')  // the formatted text, as rendered
grid.rows.values('r1')       // every readable column, as an object

value and text are different questions and the difference bites people. A currency column's value is 1234.5; its text is £1,234.50. Sorting and filtering use the value. Copying, exporting and searching use the text, because that is what the user can see and what they typed against.

Display index and row key are different things

A display index is a position: row 0 is whatever is at the top right now, and it changes when you sort. A key identifies a record for as long as it exists. Anything that has to survive a sort, a filter or a page change is keyed.

Position: fragile

grid.rows.get(4)
grid.selection.setRange({
  startRow: 0, endRow: 9, columns: ['cap'],
})

Identity: durable

grid.rows.byKey('CIR-100042')
grid.edit.setCells([
  { key: 'CIR-100042', colId: 'cap', value: 99 },
])

Everything is on one event bus

There are no onSomething configuration properties. One bus, one grid.on(type, handler), and every payload carries type, origin and grid alongside its own fields.

Subscribing

const off = grid.on('cell:changed', e => save(e.row.data));
grid.once('ready', init);
grid.on('*', e => console.log(e.type, e));   // wildcard, for working out what fires

off();                                       // every subscription returns its own unsubscribe

origin tells you where a change came from: 'user', 'api', 'init', 'undo'. It is what stops a feedback loop when you persist changes: a handler that writes to a server on cell:changed should usually ignore its own 'undo' traffic, or at least know that is what it is looking at.

Two rules hold across the whole API

Nothing is a double negative. There is no suppressX, no disableY. Options are positive and say what they enable: edit, sortable, allowGroup. Turning something off is false.

Every configuration key is settable at runtime through grid.set(key, value) and readable through grid.get(key). There is no separate "you can only pass this at construction" list to memorise.

Which version am I running?

grid.getVersion();          // '1.65.0'
LatticeGrid.getVersion();   // the same, when you have no grid to hand

On the instance as well as the module, because that is where it is wanted: a bug report says "the grid on this page", and whoever reads it has a grid rather than the module it was built from.

A headless grid is not a smaller grid, it is a grid without a renderer

createGrid builds this same core and attaches the DOM renderer to it; createHeadlessGrid stops one step earlier. Everything the core owns — data, state, sort, filter, group, total, pivot, formulas and computed columns, editing and optimistic write-back, export, and every event — runs unchanged with no renderer attached, which is why most of the examples in this guide that call createHeadlessGrid are settling real API questions, not toy snippets. What it does not have is the renderer: no layout, no measurement, no scrolling geometry, no focus, and grid.element is null.

A grid mounted where it has no rendered box paints only a handful of rows, not the whole dataset — by design, not as a bug. The visible row window is computed from the container's own height; a container that is detached from the document, or sits under a display: none ancestor, measures zero, and the virtualiser falls back to a small band around the top of the data (the overscan margin, five rows with the default configuration) rather than nothing at all. A harness that builds a grid off in a hidden or unattached container and then asserts on what is visible reads far fewer rows than it loaded and looks broken; giving the container a real, measured box before asserting is what fixes it. Note that this is about the container having no computed size, not about being visually off-screen: a container positioned outside the browser's visible area with real, explicit dimensions (for example position: fixed; left: -9999px with a width and height) still gets a real box and renders fully.

The in-repo test DOM (testdom.js) is a stub, not a browser, and says so in its own header comment. It implements exactly the surface the renderer touches — element creation, attributes, classList, style, children, textContent, event dispatch, injected geometry, and shims for ResizeObserver and requestAnimationFrame — which is enough to drive the renderer's logic in Node for most of the suite. It does not compute a cascade or a layout, so a class or style change that a stylesheet then overrides, or a box that depends on CSS rather than on the geometry a test injected, is invisible to it; those need the real-browser tests (test/*-browser.test.js) that drive an actual headless Chrome instead. It also does not model real focus semantics — its focus() simply records which element is "focused" with no check for visibility, tab order or focusability. Since BACKLOG-0001181, classList.add does throw on a token containing a space, the same InvalidCharacterError a real DOMTokenList throws, closing the specific gap where a two-word class name passed every headless test and then blanked a real page.

Defining columns

A column is an object with a field (the path to read from your data) and whatever else it needs. Everything except field or id is optional.

{ field: 'site.address.postcode', title: 'Postcode', type: 'text', width: 110 }

field reads a dot path, so nested data needs no flattening. The column's id defaults to the field, which is what you use everywhere else: in setCells, in filters, in saved state.

Grouped headers

Nest columns to get a spanning header row.

Two levels

columns: [
  { field: 'circuitId', title: 'Circuit' },
  { title: 'Location', children: [
    { field: 'region' },
    { field: 'country' },
    { field: 'site.address.postcode', title: 'Postcode' },
  ]},
]

Computed columns

A column can compute its value instead of reading one. Declare what it depends on and the grid builds a dependency graph, so editing cost invalidates margin and nothing else.

Derived values

{
  id: 'margin',
  title: 'Margin',
  type: 'number',
  format: 'currency:GBP:2',
  value: {
    deps: ['monthlyCharge', 'cost'],
    compute: (deps) => deps.monthlyCharge - deps.cost,
  },
}

// compute(deps, ctx): deps holds the resolved values of the columns you
// named; ctx carries { data, row, column, grid, context } when you need more.

compute is handed the values it declared rather than the whole row, which is what lets the grid memoise it: the same inputs give the same answer, so the result is cached until one of them changes. Reach for ctx.data when you genuinely need the rest of the row, and set pure: false if the result depends on something the grid cannot see.

Declaring deps is what makes this cheap. Without it the grid would have to assume any change might affect any computed column and recompute all of them on every edit. A cycle is caught at compile time with the full path named, rather than becoming a stack overflow at render time.

When a computed value is re-run, stated plainly:

An answer that arrives later

// A lookup the grid cannot see: show a placeholder, fill the table, then
// tell the grid which cells to recompute. The compute stays pure, so it is
// not re-run on every paint — only when you say the answer changed.
const names = new Map();
const column = {
  id: 'owner', title: 'Owner',
  value: {
    deps: ['ownerId'],
    compute: (deps) => names.get(deps.ownerId) ?? 'Loading…',
  },
};

const missing = [...new Set(grid.rows.data().map((r) => r.ownerId))].filter((id) => !names.has(id));
const resolved = await fetchNames(missing);        // { id: name }
for (const id of missing) names.set(id, resolved[id]);
const rows = grid.rows.data().filter((r) => missing.includes(r.ownerId)).map((r) => r.id);
grid.rows.refresh({ rows, columns: ['owner'], force: true });

// Or declare the column `pure: false` and it re-reads `names` on every
// paint; then a plain grid.rows.refresh() after the fetch is enough.

Sizing and pinning

Layout

{ field: 'circuitId', layout: { width: 130, pin: 'start' } }
{ field: 'notes',     layout: { flex: 1, min: 200 } }
{ field: 'isActive',  layout: { width: 90, pin: 'end' } }

Pinned columns are rendered in their own region and do not scroll horizontally. grid.columns.fit() distributes the viewport width across visible columns, and autoSize measures content.

The width fit() distributes is the space the cells actually occupy: the body viewport’s client width, read when you call it. When the grid has enough rows to scroll vertically, that width excludes the scrollbar, so the columns end flush with it instead of running under it; when there is no vertical scrollbar, it is the full inner width. Rows you passed to createGrid or grid.rows.load() before the call are counted, so calling it straight after either works.

Every column the grid draws counts toward that width, not only the ones fit() sizes. It sizes your resizable columns. A column it does not size keeps its width, and that width comes off the target first: a column declared resizable: false, and the grid’s own selection checkbox, detail expander, group and tree columns. Your resizable columns then share what is left in proportion to their current widths, within each min and max; a column held at a bound stays there and the others share the rest, so all the drawn columns together still come to the viewport width exactly. Under a pivot every column drawn is one the grid generates, so fit() has nothing to size and leaves the widths as they are.

If what is left is less than those columns’ minimums, which happens when the columns fit() does not size already take the width, each resizable column is set to its min (40px when it declares none). None goes below its minimum, and none is squeezed to nothing: the grid scrolls horizontally instead, and a [lattice] warning in the console names the widths that ran out.

fit() is one-shot. It sets a fixed width on each column once, including a flex column, and does not follow the grid afterwards. If the width changes later, because the container is resized or because rows that arrive afterwards bring a vertical scrollbar in, call it again. A column that should keep tracking the width by itself wants flex instead of fit().

Both are also on the column menu: Move left, Move right, Move to start, Move to end, and a Width submenu, and bound to the keyboard with a heading focused: Alt with a left or right arrow resizes, Shift with one moves the column. Neither operation depends on dragging.

A pinned region holds the edge of the viewport only while there is something to scroll. Where the columns are narrower than the grid: fixed widths, or a flex column that has reached its max: nothing scrolls, so the pinned columns sit directly after the centre ones and the spare width falls beyond them all, at the right of the grid.

To take that space up rather than leave it, give a column flex and no max, or call grid.columns.fit(). Note that removing a column’s width does not do it: a column with neither width nor flex takes the default 150 rather than a share of what is free. Absorbing space is what flex is for, and min only ever sets a floor.

Types and formatting

A type is not a label. It is a bundle of behaviour: how a value is formatted, parsed back from text, compared when sorting, stored in the columnar backing, written to Excel, and put on the clipboard. Setting type configures all of that at once.

FamilyTypes
Coretext, number, boolean, date, dateString, object, lookup
Temporaldatetime, timestamp, time, duration
Networkipv4, ipv6, cidr, mac
Numeric baseshex, hex8, hex16, hex32, binary, binary8, octal
Units: computingbytes, megabytes, gigabytes, bitrate, gigabits
Units: physicalmetres, millimetres, kilometres, grams, kilograms, tonnes, seconds, milliseconds, hours
Units: engineeringspeed, kph, mph, knots, acceleration, area, hectares, volume, cubicMetres, energy, kilowattHours, power, kilowatts, force, pressure, bar, psi, torque, density, flow, litresPerMinute, radians, degrees
Units: electrical and scientificvoltage, current, resistance, capacitance, inductance, charge, conductance, fluxDensity, luminousFlux, illuminance, substance, absorbedDose, equivalentDose, radioactivity, luminousIntensity, doseRate, rpm, angularVelocity, ppm, ppb, basisPoints, molarity, massFlow, tonnesPerHour, viscosity, kinematicViscosity, thermalConductivity, specificHeat, frequency
Temperaturecelsius, fahrenheit, kelvin
Currencycurrency, usd, eur, gbp, jpy
Structuredjson, colour, rating, percent

Units

A unit type stores a plain number in a named base unit and makes only display and input unit-aware. That is the whole design: the column still backs onto a typed array, so sorting, filtering, grouping and totalling stay ordinary arithmetic and never touch the rendered text. A gigabyte column holding 0.5 shows 512 MB, accepts 512M typed over it, and stores 0.5 throughout.

Declared types, and one built to order

columns: [
  { field: 'span',     type: 'metres' },      // 1500 → "1.5 km"
  { field: 'load',     type: 'kilograms' },
  { field: 'inlet',    type: 'pressure' },     // 200000 → "200 kPa"
  { field: 'bearing',  type: 'degrees' },
  { field: 'cap',      type: 'capacitance' },  // 4.7e-11 → "47 pF"
  { field: 'inletTemp', type: 'celsius' },
],

// or configure your own base unit and precision
dataTypes: {
  runtime: createUnitType({ system: 'duration', unit: 'ms', display: 'auto' }),
}

Seventeen unit systems ship: data, bitrate, length, mass, duration, speed, acceleration, area, volume, energy, power, force, pressure, torque, density, flow and angle, plus fifteen SI-prefixed electrical and scientific quantities. registerUnitSystem adds one of your own.

Ambiguous units are refused, not guessed. A US gallon and an imperial gallon differ by about a fifth, and "ton" means three different masses. Each has its own symbol: gal (US), ton (UK), and the bare word is claimed by all of them, so typing it is rejected rather than resolved. A gal silently taken as US in a UK deployment is data corruption that reads as rounding.

The same rule catches case: mV and MV are a billion apart, so both exact spellings work and the case-folded mv is refused.

Customary units are accepted but never chosen. display: 'auto' walks the coherent SI ladder only. With the calorie, the BTU and the kilojoule all on one ladder, 4,000 J would render as 3.79 BTU: auto picks the largest unit that fits, and the BTU happens to be larger than the kilojoule. Ask for a BTU by name and you get one.

significantFigures renders to a fixed precision rather than a fixed number of decimals. Two decimals is four significant figures at 12.34 kB and three at 1.54 kB, so a column claims different accuracy row by row depending on nothing but which unit auto picked; significant figures are what an instrument has, and they hold across the ladder. Rounding is applied before the unit is chosen, so 999,999 bytes to three figures is 1.00 MB rather than 1,000 kB.

compound: ['ft', 'in'] renders one stored number across an ordered subset of the system's units — a length as 5 ft 11 in, a duration as 1 h 23 m. It is display and parse only: the stored value stays a single base-unit number, so sorting, filtering, grouping and totals are the same arithmetic as any other unit column. The units are sorted largest to smallest, the smallest carries the remainder, and parsing sums the parts, so the display round-trips through a paste. The mid-value editor — keystroke roll-over between feet and inches, caret behaviour at a rung boundary — is a separate, later piece of work; this is the read-and-paste half.

Angles wrap, so their mean is replaced. The average of 359° and 1° is 0°, and the arithmetic answer (180°) is a confident, plausible number pointing in exactly the wrong direction. A degrees or radians column averages by direction instead, and reports nothing where the angles cancel and there is no mean direction to give. The sum stays arithmetic, because a total rotation of 720° is two turns and that is a real figure.

Temperature is its own type, not a unit. Every other unit is a multiplication; Celsius to Fahrenheit carries an offset, and zero Celsius is not zero anything, so no factor converts it. celsius, fahrenheit and kelvin convert on input: type 72 F into a Celsius column and it stores 22.2, and refuse to be summed: twenty degrees plus twenty degrees is not forty degrees, and a footer saying so would be believed.

Currency is its own type, not a unit either. A currency's “factor” is an exchange rate that moves, so a value carries an amount and a code and never a fixed factor. The grid ships and fetches no rates: pass a rate source through createCurrencyType({ display, rates }), and a rate that is needed but absent renders as a loud marker (missingRate), never as zero. A column totalling in a display currency refuses to add unlike currencies until every value can reach that currency. The shipped currency, usd, eur, gbp and jpy types cover the single-currency case; a rate table is denominated in a base you can state with rateBase.

Types read from the data

A column that declares no type takes one from the rows. The first hundred non-empty values of that column are sampled, and a type is adopted only if every one of them matches it; anything mixed or ambiguous stays text and says so once on the console.

One key each, and the types arrive

columns: [
  { field: 'sku' },                          // text
  { field: 'quantity' },                     // number: aligned right, numeric filter
  { field: 'shipped' },                      // date
  { field: 'expedited' }                     // boolean: checkbox editor
]

Sampling happens once, when rows first arrive. A grid built empty and filled later infers on that first load, so fetching after construction is no reason to declare types you would otherwise leave out. Later loads keep the types already settled on: data that arrives tomorrow cannot change a column's type under a formatter or an editor that was configured around it.

Only the built-in names are ever inferred. Candidates are tried in registration order, so every string reaches text and every number reaches number before an extended type is considered. A column of IP addresses or durations is text until you name the type you want.

Inference never overrules a decision you made. An explicit type, a preset that carries one, a type in columnDefaults, and a lookup all win; type: false turns sampling off and keeps the column text whatever it holds. Set sampleSize to sample more or fewer than a hundred.

What declaring a type buys you, and what it does not. Sorting is not the reason. The default comparator is value-aware, so an undeclared column of numbers already sorts 2.5 before 10 rather than lexicographically, and an undeclared column of IPv4 addresses already orders correctly across 128.0.0.1.

What a type settles is everything around the sort: which editor opens, how typed text is parsed back into a value, how the value is formatted, which filter kind the header offers, how the column is stored, and what lands in Excel and on the clipboard. That is what inference supplies for free on a plain column, and what naming a type explicitly gives you where the data cannot say it, a duration, a byte count, a network address.

Dates are stored as strings, deliberately

A date column stores '2024-03-11', not a Date. This is the single most consequential type decision in the product.

The bug it avoids: new Date('2024-03-11') is midnight UTC. Render that in New York and it is the 10th. A user in London sets a delivery date, a colleague in Mumbai opens the same grid and sees the day before. Storing the wall-clock string means the date a user typed is the date everyone sees, because there is no instant to convert.

It is also faster and smaller: ISO 8601 sorts lexicographically in the same order it sorts chronologically, so a date column sorts as text, and repeated dates dictionary-encode well.

When you genuinely mean an instant (a log timestamp, an audit time) use timestamp — see below. datetime stays a wall clock, deliberately.

timestamp — an instant, stored UTC, shown in a chosen zone

type: 'timestamp' is the sibling to datetime for data that is one genuine moment everywhere — an audit time, an event created_at, a cross-region log line — rather than a wall clock.

Stored as an instant. A value ingests from epoch-millis, a Date, or a zone-bearing ISO string (…Z / …+01:00) and is stored as epoch-millis UTC. Because storage is numeric, sort, filter and compare operate on the instant, never on rendered text — two rows from different origin zones order by true chronology, and changing the display zone never reorders them.

Shown in a display zone you control. The cell renders in the zone resolved by precedence: the column's typeOptions.timeZone, then the grid's config.timeZone, then the viewer's local zone. The resolved zone is nameable (e.g. Europe/London (BST)) so a reader always knows which clock they are reading; set typeOptions.showOrigin: true to also show the origin zone when it differs, and when no origin was recorded the cell says so rather than assuming local.

Grouped by civil day in the display zone. Grouping a timestamp column buckets by civil day by default — group: { granularity: 'week' | 'month' | 'instant', weekStart: 1 } chooses week (Monday-start by default), month, or the exact instant. Buckets are computed by projecting the instant to a civil date in the display zone, so a 23- or 25-hour daylight-saving day still collapses to one bucket rather than splitting.

Excel export. Excel has no zone, so the display-zone wall clock is written as a plain numeric datetime serial (yyyy-mm-dd hh:mm) — what you saw on screen, with the zone named in the docs and column header, never shifted silently to UTC.

Formats

Shorthand and full form

format: 'currency:GBP:2'                     // £1,234.50
format: 'percent:1'                          // 87.4%
format: 'date:dd MMM yyyy'                   // 11 Mar 2024

format: {                                    // when the shorthand runs out
  type: 'number',
  style: 'currency', currency: 'GBP', decimals: 2,
  negative: 'parentheses',                   // (£1,234.50)
  negativeClass: 'is-loss',
  nullDisplay: ', ',
}

format: { decimals: 0, signed: true }        // +5, 0, -5

signed puts a leading + on a positive number, currency or percent value. Zero gets no sign either way — it is neither positive nor negative — and a negative value is entirely unaffected: it still renders however negative says ('minus' by default). Off by default, so an existing column's negatives-only look never changes underneath it.

A key in format that the resolved type does not read — a typo, or a key that belongs to a different format shape, such as signed written on a text column — is never silently dropped. It warns once, by column and key name, instead of doing nothing.

Lookups

A lookup column stores an id and shows a label. Sorting, filtering, grouping, copying and exporting all use the label, because that is the thing the user is reasoning about, but the data keeps the id.

A status column

{
  field: 'statusId',
  title: 'Status',
  type: 'lookup',
  lookup: { options: [
    { id: 1, label: 'Open' },
    { id: 2, label: 'Pending' },
    { id: 3, label: 'Closed' },
    { id: 4, label: 'Escalated', variant: 'danger' },
  ]},
  cell: { decoration: 'pill' },
}

Options may be a function, may return a promise, and may be searched remotely with an AbortSignal so a superseded keystroke cancels its own request.

Custom types

A 32-bit register column

dataTypes: {
  reg32: LatticeGrid.createRadixType({
    radix: 2, bitWidth: 32, pad: true, signed: false, group: 8,
  }),
},
columns: [{ field: 'flags', type: 'reg32' }],

// 170 → 0b00000000 00000000 00000000 10101010

radix is 2, 8 or 16, or the names binary, octal and hex. Base 10 is not among them and is not an oversight: a decimal number is what the number type is for, with grouping, decimals, currency and notation that a radix formatter has no concept of. Passing radix: 10 names the supported set in the console and falls back to hex rather than producing something that looks like a number column but is not one.

Cells and renderers

By default a cell writes text. When you want more, cell takes a decoration, a named renderer, a template or a component.

Decorations, the common cases, without writing a renderer

cell: { decoration: 'pill' }                     // a status chip
cell: { decoration: 'bar', min: 0, max: 1 }      // an inline bar
cell: { decoration: 'heat', ramp: 'redGreen' }   // a heat fill
cell: { decoration: 'dot' }                      // a leading dot

Variants: mapping a value to a semantic colour

cell: {
  decoration: 'pill',
  variant: { when: [
    { op: 'eq', value: 'Escalated', use: 'danger' },
    { op: 'eq', value: 'Pending',   use: 'warning' },
  ], default: 'success' },
}

Variants are semantic tokens rather than colours: danger, not #c22b2b. The theme decides what danger looks like, and it looks the same in the status pill, the filter chip and the validation message. Changing the palette is one custom property, not a search for hex codes.

Icon sets: a threshold glyph per value band

// A built-in set — traffic lights, arrows, rating marks — driven by value.
cell: { decoration: { type: 'icon', iconSet: 'arrows' } }

// Or your own bands. The highest `min` a value clears wins; a band with no
// `min` is the catch-all. `label` is what a screen reader announces.
cell: { decoration: { type: 'icon', bands: [
  { min: 0.9, icon: 'success', label: 'on target', variant: 'success' },
  { min: 0.5, icon: 'warning', label: 'at risk',   variant: 'warning' },
  {           icon: 'danger',  label: 'off track',  variant: 'danger'  },
] } }

An icon set is a restatement of the value, not a replacement for it: the value still renders beside the glyph, and the band's label is set as the glyph's aria-label, so a screen-reader user hears "on target 92%" rather than a bare number with the status lost. The glyphs are the grid's own inline sprites (§16), so an icon set adds no dependency and makes no request. Built-in sets: trafficLights, arrows, trafficArrows, ratings.

Turning a decoration on at runtime

// Set, change or clear a column's decoration after the grid is built.
grid.columns.decorate('score', { type: 'bar', min: 0, max: 100 });
grid.columns.decorate('trend', { type: 'icon', iconSet: 'arrows' });
grid.columns.decorate('score', null);   // back to plain text

A decoration is presentation, so columns.decorate is a live setter like grid.set('theme', …): it is not on the undo timeline and does not travel in a saved view. For conditional styling that a user edits and a view remembers, reach for grid.formatting below, which holds colour and weight rules as durable state.

Your own renderer

components: {
  sparkline: {
    render(el, p) { el.appendChild(draw(p.value)); },
    refresh(el, p) { update(el, p.value); return true; },
    release(el) { el.textContent = ''; },
  },
},
columns: [{ field: 'history', cell: { render: 'sparkline' } }],

refresh returning true is the contract that makes recycling work: it means "I updated in place, keep this element". Return false and the grid rebuilds the cell. A renderer that only implements render still works, it is just rebuilt on every reuse.

Templates, and what allowUnsafeTemplates permits

cell: { template: '<span class="sku">{{ value }}</span>' }   // escaped

// Raw interpolation needs the grid-level opt-in:
allowUnsafeTemplates: true,
cell: { template: '<span>{{{ value }}}</span>' }

The flag permits markup, not code. Without it, {{ }} escapes and a {{{ }}} segment is refused outright. With it, an interpolated value may carry presentational markup: <b>, <a href>, a <span>, and everything executable is still stripped from it: <script>, <iframe>, <style> and the other code-bearing tags, every on* handler attribute, and javascript: or data: URLs including entity-encoded spellings of them. The same rules apply to a string returned from cell.render, which is the same gate.

Why the value is treated differently from the template. You wrote the template and can audit it; the value is row data and usually arrives from somewhere you cannot. A template is refused at compile time for a dangerous tag, but that refusal says nothing about what a value interpolated into it might contain.

It is a narrow allowance, not a sanitiser. It exists so a grid cell can show emphasis and a link. To render arbitrary third-party HTML, sanitise it yourself and return an element from cell.render.

Styling

Cells, columns and rows can all carry classes and inline styles, static or computed.

By scope

// Cells and columns: declared on the column
{ field: 'margin', cell: {
  class: 'tabular',
  classWhen: { 'is-loss': (p) => p.value < 0 },
  style: (p) => ({ fontWeight: p.value > 1e6 ? 650 : 400 }),
}}

// Rows: on the grid
rowClass: (p) => p.data.slaBreached ? 'row-breach' : null,
rowStyle: (p) => p.data.region === 'AMER' ? { borderLeft: '3px solid #7c3aed' } : null,

All of these are re-evaluated on every repaint and remove what they added last time first. That is not caution. Rows and cells come from pools, so an element that carried a class for one row will later carry a different row, a class written once and left alone smears down the grid as the user scrolls.

Theming

The stylesheet is custom properties throughout. Override the tokens, not the rules.

A house palette

.lattice {
  --lattice-accent: #7c3aed;
  --lattice-font-size: 13px;
  --lattice-space: 8px;
  --lattice-border-color: #e6e8eb;
}

Four themes ship. With no theme set, the grid follows the viewer's prefers-color-scheme between light and dark; naming one pins it. Density is separate: compact, standard, comfortable or spacious, and combines with any of them.

Pinning a theme, at build time or at runtime

createGrid(el, { theme: 'high-contrast' });
grid.set('theme', 'terminal');
grid.set('theme', null);   // back to following the viewer
ThemeWhat it is for
lightThe default. Follows prefers-color-scheme when theme is unset.
darkThe same palette inverted, with the accent and status hues re-picked for a dark ground rather than reused.
high-contrastNot "dark with more contrast". Text is 21:1 and borders 6.1:1 against the background, where the other themes sit near 1.3:1 on borders, WCAG 1.4.11 asks for 3:1 on the boundaries a user has to find. Cell borders are drawn rather than implied, selected rows carry an outline as well as a fill, and every status pill has a solid border so it does not depend on hue alone.
terminalA phosphor console: one hue on near-black, monospaced throughout. Status is carried by brightness rather than colour, so the palette stays a palette.

Forced colours

Windows High Contrast Mode replaces the palette outright: that is the point of it, and no stylesheet should fight it. What the grid does instead is translate every piece of meaning it normally carries in a background tint into something the mode preserves.

A selected row takes the system's own selection colours. A pinned region loses its shadow, which forced colours do not render, and gains a rule in its place. Status pills, fill decorations, progress tracks and histogram bars each gain a border, because a fill with no edge is invisible once its colour is discarded. Diff states stop depending on hue altogether: added, removed and changed are told apart by border style: solid, dashed and doubled : since the mode offers no way to keep four distinct colours.

Two things deliberately keep their colour, declared with forced-color-adjust: a colour swatch, where the colour is the value being shown, and a collaborator's presence colour, which is how one person is told from another. Replacing those would destroy the meaning rather than translate it. Both gain a border so they stay visible against either ground.

Every theme is the same token set with different values, so an override you write against .lattice applies to all of them, and one written against .lattice[data-theme="terminal"] applies to that one. The attribute is on the grid's own root element, not on <html>.

The grid and your page's CSS

Every selector in the stylesheet is namespaced under .lattice, so the grid cannot restyle your page. From 1.4.0 the reverse is also true: the grid gives the elements it builds a floor for the properties a page is most likely to set on a bare tag: margin, padding, border, radius, background, shadow, text transform and letter spacing, plus type and colour on form controls, which inherit neither.

Why this is needed at all. A grid is mounted inside somebody else's stylesheet. A rule as ordinary as section { padding: 5.5rem 0 }, a marketing page, a CMS theme, a Tailwind preflight: matches by tag name, and the grid builds parts of its own interface from those tags: the tool panel's filter rows are <section> elements. Without the reset, 5.5rem of somebody else's padding lands on every one of them.

The reset uses no !important. It is specificity (0,1,1) and every rule that dresses a grid element is (0,2,0) or higher, so the grid's own styling always wins and the reset only fills a gap. Yours wins too, on the same terms: a rule aimed at a Lattice class, .lattice .lat-cell { … }: outranks it, so overriding the grid deliberately works exactly as before. Only bare-tag rules are shut out.

It touches box model and decoration only. Nothing in it sets display, position or any dimension: those belong to the renderer, and a reset that reached them would break virtualisation rather than protect it.

Loading and updating data

Data usually arrives after the grid does. Build it empty, then load, the sort, filters, grouping and column layout you set up in the meantime all survive and apply to the new data.

The ordinary sequence

const grid = createGrid(el, { columns, rowKey: 'id', rows: [] });
grid.overlay.show('loading');

const data = await fetch('/api/circuits').then(r => r.json());
grid.rows.load(data);
grid.overlay.hide();

Straight from a URL, with createUrlSource

When the data is a file at a URL you do not have to fetch it yourself. createUrlSource(url, opts) loads a JSON file or streams an NDJSON/JSONL file directly, and you pass it as the source. See the Sources reference for every option.

A JSON file, and a streamed NDJSON file

// A JSON file: a top-level array, or nested via rowsPath / map.
createGrid(el, { columns, rowKey: 'id', source: createUrlSource('/data/circuits.json') });

// An NDJSON file: rows stream in as they parse, first rows first.
createGrid(el, { columns, rowKey: 'id', source: createUrlSource('/data/events.ndjson', { batchSize: 500 }) });

// Auth, a nested array, and a 30s refresh:
createGrid(el, {
  columns, rowKey: 'id',
  source: createUrlSource('/api/rows', {
    headers: { Authorization: 'Bearer …' },
    rowsPath: 'result.items',
    poll: 30000,
  }),
});

The format is inferred from the extension, then the Content-Type, then a sniff — override it with format: 'json' | 'ndjson'. A non-2xx response, a network error or a malformed NDJSON line becomes a source:error event rather than an exception, and lenient: true skips a bad NDJSON line instead of failing the whole stream.

Incremental changes

rows.load replaces everything. When you have a delta, a websocket message, a save that returned the updated record: apply just that.

Adds, updates and removals in one call

grid.rows.apply({
  add:    [{ id: 4, name: 'd' }, { id: 5, name: 'e' }],
  update: [{ id: 1, name: 'A' }],
  remove: ['3'],                // row keys, or the row objects
  at:     0,                    // optional insert position for `add`
});
// → { added: [...], updated: [...], removed: [...] }

An update is a patch. Fields absent from it are untouched, so a delta arriving from a websocket or coming back from a save can be applied as-is without reading the row first. This has to be said explicitly because the opposite: assigning the patch over the row: looks identical for a caller who happens to send whole rows and silently destroys data for one who does not.

Coalescing merges fields rather than keeping the last message. A feed sending {price} and {volume} as separate messages inside one window keeps both. Coalescing may reorder work; it may not lose it.

Flushing happens on a frame, with a timer behind it. A queued batch lands on a paint boundary, which is what makes "ten thousand updates, one repaint" true rather than usually true, a timer can fire twice between two paints. But requestAnimationFrame is not guaranteed to fire at all: a backgrounded tab stops firing it entirely. So a frame and a timer are armed together and the first to arrive wins. In a foreground tab the frame always wins, at ~16ms against a 50ms fallback; in a hidden tab the timer keeps the feed applying instead of the grid silently stalling with every caller's promise unresolved.

A long flush defers rather than blocks. updates.budgetMs caps how long one flush spends applying; over budget, the remainder returns to the queue and lands next frame, and the promise a caller is holding resolves when their rows actually land rather than when the first slice does. Slicing is by row and only for updates, a partially applied row is not a state the store should be in, and splitting a structural change would re-run the pipeline twice for one batch. stats().deferrals rising steadily means the feed is arriving faster than the grid can apply it.

Rejections are reported, never thrown. Throwing would abandon the rows that were fine. An update or remove naming a row that is not here is unknown-id; an add whose key already exists is duplicate-id and is refused, because selection, expansion, comments and the key index all resolve one key to one row and admitting a second corrupts every one of them at once.

This runs the minimum pipeline. An update touching no sorted, filtered or grouped column skips those stages entirely and only the totals and the affected cells refresh. Adds and removals are structural and re-run everything.

Removals tombstone in place rather than compacting, so every existing index stays valid, which is what lets selection, expansion state and cached permutations survive a delete.

Patching a single cell

When you have a value rather than a row, setCells patches fields in place.

One cell, or many, without row objects

grid.edit.setCells([{ key: 'CIR-100042', colId: 'capacity', value: 990 }]);

grid.edit.setCells([
  { key: 'CIR-100042', colId: 'notes',    value: 'Chased' },
  { key: 'CIR-100043', colId: 'capacity', value: 770 },
]);
// → the number of cells written

This is the full path, not a shortcut: it validates, emits cell:changed per cell, re-sorts if the column is sorted on, records one undo entry, and returns 0 for a column the user may not write. It then announces the whole call once as rows:changed (identified: true, edit: true, the updated rows and the columns written), after the per-cell events, so a derived grid, a statistic tile or anything else that follows rows:changed re-reads once for a twenty-cell paste rather than twenty times. An editor commit, a fill, a paste, an undo, a redo and an optimistic rollback each announce themselves the same way, once per batch. Earlier releases announced nothing here, and a derived view of an edited grid went silently stale.

High-frequency updates

For a ticking feed, queue batches changes to the next animation frame so a thousand messages a second produce sixty repaints rather than a thousand.

A price feed

socket.on('tick', (row) => grid.rows.queue({ update: [row] }));

Walking the data rather than the view

rows.forEach walks what is on screen: filtered, sorted, grouped, with collapsed rows left out. That is the right default, and the wrong answer for a caller totalling a column, exporting, or reconciling against another system.

let total = 0;
grid.rows.forEach(r => { total += r.data.amount });      // what the user can see
grid.rows.forEachAll(r => { total += r.data.amount });   // what the grid holds

forEachAll visits leaf rows only, in the order they arrived. Group rows are a product of the current grouping and do not exist in the data, so they are not offered; the sort belongs to the filtered view, so the order here is physical rather than sorted.

A remote or paged source holds the page it has fetched, not the whole set, so there is nothing there to walk past the filters. It warns and walks what it has rather than quietly returning the filtered rows, a caller who asked for everything and silently received a subset gets a number that looks entirely plausible and is wrong.

Sorting

Setting and reading

grid.sort.set([{ col: 'capacity', dir: 'desc' }]);

// A multi-column sort is one call, in priority order.
grid.sort.set([
  { col: 'region', dir: 'asc' },
  { col: 'capacity', dir: 'desc' },
]);

grid.sort.get();     // [{ col, dir }, …]
grid.sort.clear();

Clicking a header cycles ascending, descending, none; shift-clicking a second header adds to the sort rather than replacing it. A column can supply its own compare, and a type already has one: dates compare chronologically, IP addresses numerically rather than as strings, durations by length.

Filtering

There are two filters and they are separate on purpose. The quick filter is one string matched across every readable column. The filter set is a structured condition tree.

Quick filter

grid.filters.quick('singapore');
grid.filters.quick('');          // clear

A condition tree

grid.filters.set({
  op: 'and',
  conditions: [
    { col: 'region', op: 'in', value: ['EMEA', 'APAC'] },
    { col: 'utilisation', op: 'gt', value: 0.9 },
    { op: 'or', conditions: [
      { col: 'slaBreached', op: 'eq', value: true },
      { col: 'margin', op: 'lt', value: 0 },
    ]},
  ],
});

That structure is a published wire protocol, not an internal detail. It is what grid.state.get() serialises, what a saved view carries, and what you can send to a server to evaluate the same filter against the full dataset. A remote source hands it straight to your backend.

Operators are per family: text has contains, startsWith, matches; numbers and dates have between, gt, lte; multi-value columns have containsAny, containsAll, containsNone. blank and notBlank work everywhere.

Filters your application owns: where

A condition tree can only test what is in a column. Plenty of real filters cannot be written that way — whether this user may see the row, whether you hold an exchange rate for its currency, whether it came back from your last search call. Those go in as named predicates, and they compose with everything above.

A permission filter and a toggle, side by side

grid.filters.where('visibleToMe', row => row.owner === me, { pinned: true });
grid.filters.where('rateKnown', row => rates.has(row.ccy), { deps: ['ccy'] });

grid.filters.where();              // ['visibleToMe', 'rateKnown']
grid.filters.where('rateKnown', null);   // remove just that one
grid.filters.reapply('rateKnown');       // the rate table arrived late

There is deliberately no "a filter is present" flag. That flag is a second piece of state describing the first, and the two drift: the classic symptom is a grid that filters while the UI insists it is not, or insists it is filtering while every row passes. Here, registering a predicate is what puts it in force, and removing it is what takes it out.

Three options shape one. deps names the columns the predicate reads, exactly as value.deps does for a computed column: the verdict is then cached per row and re-run when one of those columns changes on that row, not when an unrelated one does. Leave it off and the predicate is assumed to read the whole row, so it runs every pass and can never be stale. pinned makes a predicate survive filters.clear(), which is what you want for permissions and tenant scoping and not much else. condition gives the predicate a declarative twin that is pushed to the source while the function stays as the residual, so a pushdown engine narrows the fetch instead of your code filtering a page.

Migrating from AG Grid's external filter: its three pieces become two. isExternalFilterPresent() goes away, because registration is presence. doesExternalFilterPass(node) becomes the predicate itself. onFilterChanged() becomes deps where the grid can watch the change for you, and reapply(name?) where it cannot.

What travels in a saved view is the name, not the function. state.get() carries where: string[]; your predicates are your code and the grid will not pretend it can serialise them. Applying a view that names a predicate you have not registered reports the skip instead of quietly showing a wider row set, and never removes a predicate the view did not mention.

Grouping, totals and pivot

Group by one or more columns

grid.columns.group(['region', 'country']);
grid.rows.expandAll();
grid.rows.collapse('EMEA');

Expand all and Collapse all are on the menu too, once the grid is grouped: the data area's own right-click menu and every column's header menu (the 3-dot button and a right-click on the heading alike) carry both, driving grid.rows.expandAll()/collapseAll() — the same public API a host calls directly (BACKLOG-0001305). They are hidden, not disabled, on an ungrouped grid: there is nothing for either to act on. Both flow through the same contextMenu/ columnMenu customisation chain as every other built-in item, so a host filtering or extending the menu sees them as ordinary items — matched, like every other built-in, by their translated name (catalogue keys menu.expandAll and menu.collapseAll, the same ones the generated group column's own header menu already used — see your own menu items).

groupPanel: true adds a drag-and-drop strip above the column header — the row-group panel. A user drags a heading into it to group by that column; the active groups show as removable, reorderable chips, and dragging one chip past another changes the nesting order. It is keyboard-operable, so grouping is not drag-only: arrows move between chips, Shift with an arrow reorders, Delete ungroups, and an add control at the end groups any column. Every change is spoken through the live region. The strip drives grid.columns.group() — it is the same grouping model, surfaced as chrome — so a group made in the strip, from the column menu or through the API is one state, not three.

Turn the group-by strip on, and group through the model

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// `groupPanel` is chrome, so the strip itself needs the DOM build; the config
// key is accepted everywhere, and it drives the ordinary grouping model — which
// is what a headless grid can show. The strip renders the state below as chips.
const grid = createHeadlessGrid({
  columns: [{ field: 'region' }, { field: 'country' }, { field: 'sales', type: 'number' }],
  rows: [
    { region: 'EMEA', country: 'UK', sales: 10 },
    { region: 'EMEA', country: 'DE', sales: 20 },
    { region: 'AMER', country: 'US', sales: 30 },
  ],
  groupPanel: true,
});

// Order is nesting order, outermost first — exactly the order the chips show.
grid.columns.group(['region', 'country']);
const groups = grid.state.get().group;
grid.destroy();
return `grouped by ${groups.join(', ')}`;

kpis is the same idea for the tile every dashboard opens with. A createStat renders a KPI tile — a label, a value, its change against a baseline — reading the grid so it agrees with the grid; what it needs is a container and the wiring to place it. kpis is that placement done by the grid: an array of stat specs becomes a labelled band of tiles above the column header, and the grid creates the container for each and drives createStat itself. Each entry takes the fields createStat takes — of, fn, title, interval, footer, format and the rest — minus grid and container, which the grid supplies. The tiles follow the grid's filters, so the strip cannot disagree with the table beneath it. Off by default and free when absent; no kpis, no band.

A built-in KPI strip, following the grid

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// `kpis` is chrome: with createGrid it renders a labelled band of stat tiles
// above the header, each following the grid's filters. The config is accepted
// everywhere, and each tile reads the same kernel the totals row uses — which
// is what a headless grid can show.
const grid = createHeadlessGrid({
  rowKey: 'region',
  columns: [{ field: 'region' }, { field: 'mrr', type: 'number' }],
  rows: [
    { region: 'EMEA', mrr: 1200000 },
    { region: 'AMER', mrr: 2400000 },
    { region: 'APAC', mrr: 600000 },
  ],
  kpis: [
    { title: 'Accounts', fn: 'count' },
    { title: 'Revenue', of: 'mrr', fn: 'sum' },
  ],
});

// The value the "Revenue" tile would read, from the kernel createStat uses.
const revenue = grid.statistics.reduce('mrr', 'sum');
grid.destroy();
return `revenue ${revenue}`;

Totals

{ field: 'capacity', type: 'number', total: 'sum' }
{ field: 'margin',   type: 'number', total: 'avg' }
{ field: 'lastSeen', type: 'datetime', total: 'max' }

{ field: 'weighted', total: (values, rows) => weightedMean(values, rows) }

Totals appear on every group row and on the grand total. grandTotalRow: 'bottom' pins the grand total below the rows instead of leaving it inline.

On a memory source totals are maintained incrementally: a cell update moves the running value by the difference rather than re-reducing the column, so a totals row costs the same on a million rows as on a thousand. This applies to both the grand total and each group subtotal, for sum, avg, countValues, min and max on numeric columns. A cell edit that moves a row between groups is subtracted from its old group and added to its new one; only the affected groups are touched. Everything else re-reduces, and so does the incremental path itself whenever it cannot reach the right answer:

CaseWhat happens
sum, avg, countValuesMaintained by difference, with a compensated running sum so a long session does not accumulate floating-point error.
min, maxMaintained while values move past the extreme. A value moving off the current extreme re-reduces, because a running extreme cannot know what the next one is.
countThe number of rows in scope, already a single read.
A custom total functionRe-reduced on every change. A reduction supplied as a function has no inverse, so there is nothing to apply a difference to.
Adding or removing rowsRe-reduced once, then incremental again.
Filtering, sorting or groupingRe-reduced once, because the rows contributing to the total have changed.
Totals above ~1e15Re-reduced. Past that magnitude a small change no longer moves a 64-bit float, and a running total would silently stop tracking the data.
Group subtotalsMaintained incrementally the same way the grand total is: an in-group edit applies the difference, and a cross-group move subtracts from the old group and adds to the new. A min/max move that leaves a group's current extreme reseeds only that group. A custom group total, or a statistical reduction, keeps the full per-group pass.

Nothing has to be configured for this, and the reported number is the same either way, where a running value cannot be trusted, the column falls back to a full pass rather than reporting a value it is unsure of.

headerControls

The per-column header controls — the sort arrow, the filter funnel and the menu button — appear on hover by default, which keeps a wide header from reading as a row of identical icons. headerControls makes that a mode: 'hover' (the default, unchanged), 'always' to keep them visible, and 'hidden' for a clean read-only heading that draws none of them and leaves them out of the tab order. It is a grid-level default; a column's own headerControls overrides it for that column.

Each leaf heading carries the resolved mode as data-controls, and the theme keys the reveal off it, so 'always' shows the controls without a hover and 'hidden' removes them from the layout. This differs from showColumnFunctions: false, which also drops the furniture but keeps sorting, filtering and the menu reachable from the keyboard; 'hidden' is the read-only choice that takes them away outright (BACKLOG-0000982).

A grid that hides its controls by default, with one column that keeps them

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  headerControls: 'hidden',                       // the read-only default for every column
  columns: [
    { field: 'name' },                            // follows the default: hidden
    { field: 'region', headerControls: 'always' }, // this column overrides the default
    { field: 'qty', type: 'number' },             // follows the default: hidden
  ],
  rows: [{ name: 'a', region: 'x', qty: 1 }],
  rowKey: 'name',
});

// Resolve each column the way the header renderer does: its own value wins,
// then the grid default. Only the overriding column shows its controls.
function shown(id) {
  const own = grid.columns.get(id).def.headerControls;
  return (own || 'hidden') !== 'hidden';
}
const visible = ['name', 'region', 'qty'].filter(shown).length;
grid.destroy();
return visible;

showTotalInHeader

Under grouping or pivot, a totalled column's cells hold an aggregate rather than a row's own value. On by default, the heading says which, a small SUM line above Capacity, AVERAGE above Margin. The heading returns to the column's own title when grouping and pivot are both off.

Leave the headings alone

showTotalInHeader: false

The reduction goes on its own line rather than reading Sum of Capacity across one. A header cell reserves width for its sort, filter and menu buttons whether or not they are showing, so the label gets well under half the column: on a default column, 54px of 129px. One line truncated to Sum o…, trading the column's identity for its reduction. Stacked, it costs no width at all.

Such a heading carries data-total on its header cell, naming the reduction, and its two lines are .lat-header-total-fn and .lat-header-total-name. --lattice-header-total-size and --lattice-header-total-color set the reduction line's size and colour; the full phrase is on the label's title for the pointer. Where a pivot has a single value column its leaf is titled with the pivot value rather than the column's name, and that heading is left alone: it is naming the category, not the measure.

A total beside the pivoted columns

The grand total across every pivot value

pivot: { groupTotals: 'after' }        // or 'before'; omitted, none
pivot: { groupTotals: 'after', totalsLabel: 'All regions' }

It answers the question the pivot took apart. Pivoting by Country turns one Sales column into one per country, and the number a reader most often wants next is the one that was there before: sales across all of them. 'before' puts that group at the near edge, beside the row headings; 'after' puts it at the far edge, which is where a spreadsheet puts a grand total.

It costs a column, not a pass. A pivoted group row still carries its reduction over every one of its leaves, which is exactly the total across all pivot values, so these columns read a number that has already been computed. They also count towards maxColumns, since they are columns like any other.

Opt in. Omitted, a pivot has the columns it has always had, so the option cannot quietly change what an existing grid exports or what a saved view restores.

totalFilteredOnly

Total the dataset rather than the view

totalFilteredOnly: false

By default a total describes what is on screen: filter the grid and every total moves with it. Setting this to false makes the filter a lens instead: totals report the whole dataset no matter what is filtered out. Both the grand total and each group total follow the setting, so a group row shows the total for every row belonging to that group, not only the ones currently visible.

A group whose every row the filter removed has no row to appear on, but its rows still count toward the totals above it. Editing a hidden row moves the totals, because it is part of the dataset they describe: under the default it does not, because it is not part of the view they describe.

The count shown on the grand total row follows the total, so it never reports fewer rows than the total covers. Group row counts stay as the number of rows a user can expand to see, which is what that number is for.

The unfiltered row set and its grouping are computed once and reused, so the cost lands when rows are added or removed rather than on every edit or filter change. Reducing over the full dataset is more work than reducing over a filtered subset: on 500,000 rows grouped and half-filtered, a single-cell update measured 2.1ms by default and 3.2ms with this off.

totalOnlyChangedColumns

Skip the columns an edit did not touch

totalOnlyChangedColumns: true

By default the total stage considers every totalled column on every change, even columns the change did not touch. Switching this on considers only the columns whose values actually moved, and an update that rewrites a field with the value it already held reduces nothing at all, which is what a feed resending unchanged fields looks like. This narrows which columns are looked at; the incremental grand total and group subtotals described above narrow how each one is brought up to date, and the two compound.

On a million rows in seven groups, a single-cell update with four totalled columns:

The update changesOffOn
One of the four columns15.4ms7.3ms
All four columns15.0ms14.5ms
Nothing: same values rewritten15.6ms7.3ms

The saving is proportional to the totalled columns an update leaves alone, so there is nothing to gain when every totalled column changes on every update.

It is off by default because it is an assertion, not just an optimisation. Skipping a column assumes its total depends on nothing but that column's own values. That is true of every built-in reduction. It need not be true of a total supplied as a function, which also receives the row, the grid and config.context: such a total is only recomputed when its own column changes, so a function that reads application state outside the column will report the value from the last time that column moved. Leave the option off if any of your total functions work that way.

Anything that changes which rows a total covers: adding or removing rows, filtering, sorting, grouping, or changing which columns are totalled: reduces everything again regardless of the option.

Pivot

grid.columns.group(['region']);
grid.columns.pivot(['statusId']);   // a column per distinct status

Pinned rows

A pinned row sits outside the scrolling body, against the header or above the status bar, and stays there while the rows scroll past it. Use one for a column-units line, a target or budget to compare against, a precomputed summary, or a note that must not scroll away.

Pinning a units row

createGrid(element, {
  columns,
  rows,
  pinnedTopRows: [{ product: 'Units', capacity: 'MW', margin: '%' }],
});

// Or at runtime, at either edge:
grid.setPinnedRows([summaryLine], { edge: 'top' });
grid.setPinnedRows([], { edge: 'top' });        // clear

The objects are yours and are rendered through the ordinary column pipeline: value getters, formatters, cell renderers and conditional formatting all run, so a pinned row looks like the data it sits against without you rebuilding any of that.

They are not part of the data, and that separation is the point. A pinned row is not counted by rows.count(), not sorted, not filtered, not grouped, not selectable, not included in a total and not exported. A units row that sorted itself into the middle of the data, or a target line that was added to the sum it is there to be compared against, would be worse than no feature at all. If you want a row that behaves like data, make it data.

A filter that matches nothing still leaves the pinned rows visible, which is usually what you want: an empty grid with its column-units line is readable, and an empty grid without one is not.

PointBehaviour
OrderRows appear in the order of the array. At the bottom edge, the grand total comes first and your rows sit below it.
HeightFrom rowHeight, including the function form, which is called with the pinned row, so you can measure your own content. The body reserves exactly the strip's height, so no data row hides underneath it.
UpdatingPass a new array. Array identity is how the grid knows the rows changed; pushing into the array you passed before will not repaint.
EditingA pinned row has no place in the store to write to, so it is not editable.

Full-width rows

A row drawn as a single band across every column instead of being divided into them: a section banner, an explanatory note, an empty-group message, a “load more” affordance, anything that belongs between rows and is not itself divided by the columns.

A banner before each section

createGrid(element, {
  columns,
  rows,   // your data, with the banners in it
  fullWidth: {
    when: (row) => row.data.kind === 'section',
    render: ({ data }) => data.title,
  },
});

render returns a string for text or a node for content, or returns nothing and writes into params.element itself. An HTML string is deliberately not accepted. params carries { row, data, index, grid, element }.

The band holds still while the columns scroll under it, which is what a banner is for: text that scrolled sideways out of view with the columns would be a worse version of a cell. It is drawn over the pinned regions as well as the centre, so it genuinely spans every column.

A full-width row is still one of your data rows. It is counted by rows.count(), sorted, filtered and exported like any other; only its presentation changes. That is the difference between this and pinned rows, and it is the thing to get straight before choosing between them: full-width changes how a row looks, pinned changes whether a row is data at all.

So if your banners must not appear in an export or a row count, they should not be in the data. If they are section headings that belong with the records they head, and should sort, filter and export alongside them: this is the right tool.

One consequence worth stating plainly: sorting reorders banners along with everything else, because the predicate follows the row and not its position. Either do not offer sorting on such a grid, or sort on a key that keeps each section together.

A band is exposed as a row containing one cell with aria-colspan covering every column, so a screen reader reads it as one wide cell rather than as a row with missing ones. No second, empty row is rendered underneath it.

Forming banded headers at runtime (BACKLOG-0000739)

Banded headers can be declared in config (columnGroups) and now also formed, renamed, moved and dissolved at runtime through grid.columns, with a keyboard equivalent for every action. The model is the single source of truth: a band made by interaction is the same ColumnGroup tree config drives, and it round-trips through a saved view.

The API. groupColumns(ids, { title, groupId }) wraps columns in a new band or adds them to an existing one; pass id instead of groupId to create a new band with a caller-chosen, stable id you can address later (groupColumns(ids, { title: 'Traffic', id: 'g-traffic' })). ungroupColumn(id) takes a column out (dissolving a band it empties); renameGroup(id, title), dissolveGroup(id) and moveGroup(id, to) do the rest. Each emits columngroup:changed. A band's columns are always contiguous, and a nested band dissolves into its parent, not the root.

The keyboard (WCAG 2.1.1). From a focused header cell: Ctrl+Shift+/ groups the column with its neighbour on that side (joining an adjacent band, or forming a new one); Ctrl+Shift+ takes it out of its band; and Alt+Shift+/ moves the whole band as a unit. Every action is announced through the live region, and a refusal — "not in a band", "cannot be moved there" — is announced too, never silent.

Pinning and visibility. A band lives in one pin region and draws over its visible columns there: hiding a column shrinks the band's span without changing the band definition, and hiding the last visible column hides the band. A band is exposed to assistive technology as one role="columnheader" cell with an aria-colspan.

Group rows you draw yourself

The grid's own group row is an expander, a label and a count. When the heading has to carry more than that — a sprint section with a chevron, the section name, the points summed across it, a done/total count and a progress bar — groupRenderer hands you the whole row. It is drawn as one band across every column, over the pinned regions, and no ordinary cells are mounted underneath it.

A section header with a rollup the grid was never told to compute

createGrid(element, {
  columns,
  rows,
  groupRenderer: ({ value, leafCount, expanded, leaves }) => {
    // `leaves()` is the group's own rows. Roll up whatever you like.
    const rows = leaves();
    const points = rows.reduce((sum, r) => sum + r.data.points, 0);
    const done = rows.filter((r) => r.data.done).length;
    const pct = Math.round((done / leafCount) * 100);
    return `<span data-lat-group-toggle>${expanded ? '▾' : '▸'}</span>
      <b>${value}</b> ${points} pts · ${done}/${leafCount}
      <progress value="${pct}" max="100"></progress>`;
  },
});

What the renderer is given

FieldWhat it is
keyThe group's key — the same string rows.expand() and rows.collapse() take, and the one group:toggled carries.
columnThe id of the column this level groups on. It is stamped per rebuild from the grouping, not derived from the row's depth, so it stays correct when an outer grouping is removed and this level becomes the outermost.
valueThe value this group stands for.
levelDepth of the group. Zero is the outermost.
expandedWhether the group is open. Draw your chevron from this; the row is re-rendered when it changes.
leafCountHow many records sit beneath the heading, at any depth. Read this when the size is all you need.
totalsThe group's own reductions by column id — whatever total asked for. Unaffected by drawing the row.
leaves()The rows beneath the heading, computed when you call it. The members the filters left, in display order, so a rollup agrees with the rows drawn below. Also available on its own as grid.rows.leavesOf(key).
toggle()Expand the group if it is closed, collapse it if it is open.
grid, row, elementThe grid, the group row model, and the element to fill if you would rather write into it than return anything.

A string here is markup, and in fullWidth.render it is text. That difference is deliberate, and it is the difference between the two features. A full-width row renders one of your data rows, and §8.9 keeps host markup behind allowUnsafeTemplates everywhere a data value reaches the page. A group heading has no data row at all — the grid synthesised it from the grouping — so its string can only be a template you wrote in your own source. That is the same footing the board's cardRenderer already stands on, and the header this exists for (a chevron, a bar) is unwritable without it. If you interpolate a value that came from outside your application, escape it yourself, or build a node and return that instead.

Your chevron is yours to wire. The grid's own expander binds a click handler to the button it built; a string cannot carry a handler, so that chevron would be dead. Any element in your markup carrying data-lat-group-toggle expands or collapses the group it sits in, and toggle() does the same from a node you built yourself.

leaves() is a function on purpose. A group is unbounded and the renderer runs as rows are painted. Handing every group an array of its members would cost a hundred thousand rows on a heading nobody looked at. Call it when you need the rows; read leafCount when you need the size.

Which groups start open

groupDefaultExpanded decides the state of a group before anyone has touched it: true (the default) opens them all, false closes them all, a number opens the first N levels, and a predicate answers per group. Once the user or your own code expands or collapses a group, that decision stands — the default is not consulted for it again, so a group cannot spring shut under the user on the next rebuild.

The current sprint open, everything else closed, executed

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// The renderer the grid calls for each group row. Called here directly too,
// with the params the grid builds, so this block shows its actual output.
const groupRenderer = (p) => {
  const rows = p.leaves();
  const done = rows.filter((r) => r.data.done).length;
  const points = rows.reduce((sum, r) => sum + r.data.points, 0);
  return `${p.value} ${done}/${p.leafCount} ${points}pts ${p.expanded ? 'open' : 'closed'}`;
};

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'section' }, { field: 'points', type: 'number', total: 'sum' }, { field: 'done' }],
  rows: [
    { id: 1, section: 'Sprint 12', points: 3, done: true },
    { id: 2, section: 'Sprint 12', points: 5, done: false },
    { id: 3, section: 'Backlog', points: 8, done: false },
  ],
  groupRenderer,
  // Per group, not merely per depth: only the current sprint starts open.
  groupDefaultExpanded: (group) => group.value === 'Sprint 12',
});
grid.columns.group(['section']);

const out = [];
for (let i = 0; i < grid.rows.count(); i++) {
  const row = grid.rows.get(i);
  if (!row.group) continue;
  out.push(groupRenderer({
    row, key: row.key, column: row.groupColumn, value: row.groupValue,
    level: row.level, expanded: row.expanded, leafCount: row.leafCount,
    totals: row.totals, leaves: () => grid.rows.leavesOf(row.key),
    toggle: () => {}, grid, element: null,
  }));
}
grid.destroy();
return out.join(' | ');

grid.rows.leavesOf(key) is the same reach on its own, for a breadcrumb, a side panel or a rollup computed away from the row: it returns the leaf rows beneath a group heading, which is what leafCount counts.

Row reorder

rowReorder: true puts a drag handle in the first visible column and lets a user move rows with it, or with Alt+Shift+/. { column: 'name' } puts the handle somewhere else.

Reordering a list, and saving the result

createGrid(element, {
  columns,
  rows,
  rowReorder: true,
});

grid.on('row:moved', ({ key, from, to }) => {
  // rows.data() is the new order in full.
  api.saveOrder(grid.rows.data().map((r, i) => ({ id: r.id, order: i })));
});

The order is your data, not a view of it. A move reorders the array you gave the grid and tells you it happened; writing it somewhere permanent is yours, because only you know where the order lives. A grid that rearranged rows on screen and stopped there would look finished and lose the order on the next load, which is worse than not offering the feature.

If the save fails, move it back: grid.rows.move(key, from).

It refuses while a sort, filter or grouping is active, and says why out loud rather than springing the row back in silence. The reason is that dropping between two visible rows says nothing about where the row belongs in the underlying array: under a filter there may be hidden rows between them, and under a sort the displayed order is something the grid computed rather than something the data says. Rather than pick an interpretation and put the row somewhere you did not ask for, the move is declined. rows.move() returns { moved: false, reason } so you can handle it yourself.

Moving rows between grids

A row can be dragged out of one grid and into another, a picker beside a basket, an inbox beside a queue, an available list beside an assigned one.

A one-way drag, from a catalogue into a basket

// Sends, never receives. rowReorder draws the handle a drag starts from.
createGrid(left, { columns, rows,
  rowReorder: true,
  rowTransfer: { receive: false, group: 'order' },
});

// Receives, never sends. Draws no handles at all.
createGrid(right, { columns, rows,
  rowTransfer: { send: false, group: 'order' },
});

mode: 'copy' on the sending grid leaves the row where it was. group restricts exchange to grids sharing the same name, so two unrelated grids on a page do not accept each other's rows.

EventFired onCarries
beforeRowReceivethe target, before the insert{ data, at, overKey, source }, cancellable
rowReceive:cancelledthe target, on a veto{ data, at, overKey, source, reason }
row:receivedthe target{ data, at, overKey, rejected }
row:sentthe source, on a move{ key, data, mode }
row:copiedthe source, on a copy{ key, data, mode }

Those events all describe a drag that has settled. To follow one while it is happening — to highlight a candidate row, drive your own drop indicator, or update a side panel as the row travels — there are four more, and they all fire on the grid the drag started in, for a same-grid reorder and a cross-grid transfer alike. A drag is one gesture with one owner, and the source is the only grid present for the whole of it, where the pointer may cross several others or none; over names whichever grid the event is about, so one subscription can decorate any of them.

EventFires whenCarries
rowDrag:startedthe press passed the drag threshold{ key, data, over, at, overKey }
rowDrag:movedthe pointer is over a candidate position; at most once per animation frame{ key, data, over, at, overKey }
rowDrag:leftthe pointer left a grid; over is the grid it left{ key, data, over, at: null, overKey: null }
rowDrag:endedthe gesture ended, drop or no drop{ key, data, over, at, overKey, dropped }

Highlight the row a drag is hovering, and clean up however it ends

backlog.on('rowDrag:moved', (e) => {
  // e.over is the grid under the pointer, null over none.
  paintCandidate(e.over, e.overKey);       // overKey is null where there is no row to name
});
backlog.on('rowDrag:left', (e) => clearCandidate(e.over));
backlog.on('rowDrag:ended', (e) => {
  clearCandidate(e.over);                  // always fires, even released off every grid
  if (!e.dropped) toast('Nothing moved');
});

Notifications, not gates. None of the four is cancellable and none carries preventDefault. The drop is already vetoable twice over — beforeRowMove for a reorder, beforeRowReceive for a drop into another grid — and a third veto on the same gesture would be a third place to look when a drop does not happen.

rowDrag:moved is coalesced to one event per animation frame, carrying that frame's latest pointer position. A pointer produces several hundred moves a second and a handler that draws cannot usefully run faster than the display, so the rate is capped at the display's rather than the pointer's. The other three fire on the transition itself, and no rowDrag:moved is ever delivered after rowDrag:ended.

Read, measure and draw in these handlers; do not mutate. The drag resolves where it would land against the display order, so changing rows, sort, filters or grouping mid-gesture moves the ground under the drop — and data is the source row's own object rather than a copy, so writing to it edits a row that is still in the grid without announcing the change. Work that changes the grid belongs in beforeRowReceive, which is asked before the insert, or in the settled events afterwards.

The end is always reported. Exactly one rowDrag:ended follows every rowDrag:started, including a release outside every grid, where over is null. A press that never passes the drag threshold is a click and raises none of them; a grid destroyed mid-drag raises no rowDrag:ended.

A drop can mean something other than a move. Dragging a backlog row onto a row in another grid often means assign this to that: the host wants to know which row it landed on, record the relationship, and keep the row where it was. beforeRowReceive fires on the receiving grid before the insert, naming the row under the pointer as overKey (null past the last row, on empty space, on the header or on a pinned row) and the grid it came from as source. preventDefault(reason) stops the insert, and the source grid is untouched: the row stays, and neither row:sent nor row:copied fires. The handler may be async, as every before-event may; a drop whose row under the pointer, or source row, is gone by the time it settles is cancelled as 'stale'.

Assign on drop, rather than move

assigned.on('beforeRowReceive', (e) => {
  if (e.overKey === null) return;   // dropped on no row: let it move
  e.preventDefault('assigned');           // the backlog row stays in the backlog
  assign(e.data.id, e.overKey);           // the relationship is the change
});
assigned.on('rowReceive:cancelled', (e) => console.log(e.reason));  // 'assigned'

Off by default, and both ends have to agree. Rows leaving a grid is a data change you have to want: a grid that quietly let its rows be dragged away would lose one to a mis-drag, and there is no gesture a user would think to try to get it back. A one-way relationship is a declaration on both grids rather than a convention, the sender refuses to receive, and the receiver never starts a drag.

The target adds before the source removes. If the add is refused: a duplicate key, most likely: nothing is removed, so a rejected transfer loses no data. The other order would delete a row and then discover it had nowhere to go.

The row object is cloned, not shared. Two grids holding the same object would edit each other's rows through it, which is the sort of coupling nobody goes looking for when a cell changes in a grid they were not touching.

A grid is highlighted while a dragged row is over it only when it would actually accept the drop. Marking one that is going to refuse promises a placement that will not happen. A refusal is announced rather than left silent.

Picking up a row shows it, wherever the pointer goes. The row being dragged dims in its own grid, and a small label naming it follows the pointer for as long as the drag is held: over the gap between two grids, over one that is about to refuse the drop, anywhere the row's own dimming cannot reach. Both clear on release, and a handle press never also starts a range selection underneath it.

Column tags

Tag columns, then let a user show only the ones carrying a chosen tag. Sixty columns of monthly figures across five years become twelve by picking a year.

Five years of months, filtered to one

createGrid(element, {
  columns: [
    { field: 'account', title: 'Account' },              // no tags
    { field: 'jan24', title: '01/24', tags: ['2024', 'Q1'] },
    { field: 'feb24', title: '02/24', tags: ['2024', 'Q1'] },
    // …
    { field: 'total', title: 'Total' },                  // no tags
  ],
  rows,
  columnTagFilter: true,
});

Only tagged columns are ever hidden. That is the rule the whole feature turns on. A financial grid with sixty month columns also has an account name, a total and a variance, and none of those belong to a year: if filtering hid them the view would be useless, and tagging every column merely to keep it visible would be busywork. So "show 2024" does not mean "hide everything else"; it means "hide tagged columns that are not 2024".

The same rule runs the other way: an untagged column you hid yourself stays hidden, because forcing it visible would undo a decision that has nothing to do with tags.

A column can carry more than one tag, which gives you a second axis for free: tag each month with its year and its quarter, and a user can pick either. The dropdown lists tags in the order they were declared rather than alphabetically, since they are usually already in a meaningful sequence and sorting would put Q10 before Q2.

MemberDoes
columns.tags()Every distinct tag, in declaration order.
columns.showTagged(tags)Show only the columns carrying one of these. Nothing, or an empty list, shows all. Returns the ids it hid.
columns.activeTags()What is being shown, empty when all are.
columns:taggedFired with { tags, hidden }.

Anomaly summary chip

Give a column an anomalyFlag shadow (§9.4) and turn on anomalySummary, and the grid shows a small chip reading how many rows that column flags. Click it to filter the grid to exactly those rows; click again to clear.

One shadow column, counted and filtered on a click

createGrid(element, {
  columns: [
    { field: 'reading', title: 'Reading', type: 'number' },
    { field: 'outlier', title: 'Anomaly', shadow: { kind: 'anomalyFlag', of: 'reading', threshold: 3.5 } },
  ],
  rows,
  anomalySummary: true,     // or { column: 'reading', label: '…' }
});

The count and the filter are the same question. The chip is not a second detector: it reads the anomalyFlag shadow column, so the number it shows is the number of rows that column marks, and the click sets a filter on that same column ({ col, op: 'eq', value: true }). The count comes from grid.statistics.anomalies() over the shadow's base column, so the chip adds no detection of its own — the score is the API's, shown with its method, never an opaque verdict.

Off by default, and it draws nothing unless a column carries an anomalyFlag shadow. When more than one does, column names the base to summarise.

Headings without the controls

A dense grid often wants the heading and nothing else. showColumnFunctions: false leaves each heading as its label, with no sort, filter or menu control, they are not drawn rather than hidden, so the label has the whole cell. Sorting, filtering and the column menu stay reachable through the API, the keyboard and the tool panel; only the furniture goes. The resize grip stays, since dragging a column wider is a view adjustment rather than a function of the column.

Aligned grids

Two or more grids that read as one table split into sections: a summary band above a detail grid, two datasets side by side under identical columns, a frozen top section that is genuinely different data rather than a pinned row.

A summary band above a detail grid

const summary = createGrid(top, { columns, rows: totals });
const detail  = createGrid(bottom, { columns, rows, alignedGrids: [summary] });

Declare it on the grid you create last, since that is the only one that can name the others. The link is peer-based once made: whichever grid the user resizes is the one the others follow.

SharedIndependent
Column widths and flexSort
Column orderFilters
Column visibilitySelection
PinningGrouping and totals
Horizontal scrollVertical scroll, and the rows themselves

What is not shared is the design. If sort, filters and selection travelled too, this would not be a feature, it would be one grid with extra steps. The reason to have two is that the sections hold different data, so each keeps its own view of it.

Vertical scroll stays independent for the same reason: the grids hold different numbers of rows, and yoking them would make the shorter one run out.

A column one grid has and another does not is skipped rather than invented, and nothing checks that the column sets match: aligning grids with different columns is a caller error that produces a visibly wrong result rather than a silent one. Destroying any grid releases its link and leaves the rest working.

Totals that a type can refuse

Some values do not add up the way plain numbers do. A data type can say which aggregates are meaningful for it, and supply its own arithmetic where the built-in one would be wrong.

The failure this prevents is a confident wrong number. You cannot add decibels: 90 dB and 90 dB make 93 dB, not 180. The mean of a column of rates is not the mean, a 100% conversion on two visits and a 1% conversion on ten thousand average to 1.02%, not 50.5%. Both mistakes produce a plausible figure rather than an error, and a footer nobody can check gets used. A missing total gets asked about; a wrong one does not.

A type declaring what it supports

{
  base: 'number',
  totals: {
    // Anything else is refused when a column is configured, not at render.
    supported: ['sum', 'avg', 'min', 'max', 'count', 'countValues'],
    // And where the built-in arithmetic is wrong, replace it.
    implement: { sum: (values) => logDomainSum(values) },
  },
}

A type that declares no totals supports everything, so nothing that shipped before this behaves differently. An implement function receives the values index-aligned with their rows and a context carrying column and valueAt(colId, i), which is how a weighted mean reaches the denominators in another column.

The types that use it

TypeWhat it does differently
decibelSums and averages in the linear domain and converts back. Power scale, factor 10.
decibelAmplitudeThe same, on the field scale: factor 20, for voltage and current.
ratioAverages by weight, using the column named in typeOptions.weight. Refuses sum, since two rates do not add to a rate.
percentRateAs ratio, displayed with a percent sign.

A conversion rate averaged properly

{ field: 'conversion', type: 'percentRate', total: 'avg',
  typeOptions: { weight: 'visits' } }

Without a weight column the average returns nothing rather than falling back to the unweighted mean: falling back would be the exact mistake the type exists to prevent, arrived at silently. Rows with no rate, or no weight, are left out rather than counted as zero.

Choosing an aggregate at runtime

With aggregateChooser on, the column menu's totalling entry becomes an Aggregate submenu. It offers only the reductions the column's type says are meaningful (see above) — sum, avg, min, max and the counts on a plain number, but never sum on a category or a rate — with the current one ticked and a None to stop totalling. It is the same keyboard-operable menu as everywhere else: arrows move, Enter or Space picks, Escape closes and returns focus, and the ticked item reads as aria-checked to a screen reader.

Off by default; turn it on

createGrid(element, { aggregateChooser: true });

It reuses the totals model, it does not fork it. Every choice drives grid.columns.setTotal(id, name), the same public call the old toggle used, so the footer, the group rows, the pivot cells and the grand total all move together and no aggregation is recomputed here. grid.columns.aggregates(id) returns the list the submenu offers, so a host building its own chooser reads the same answer.

Safety holds on both routes. The submenu only lists meaningful aggregates, and setTotal refuses an unmeaningful named total whether it comes from the menu or from an API caller — the wrong footer cannot be reached from either.

Off by default and non-breaking. Left off, the menu keeps its plain Total this column toggle, so an existing grid is unchanged.

Setting an aggregate at runtime, executed

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  aggregateChooser: true,
  columns: [{ field: 'amount', type: 'number', total: 'sum' }, { field: 'region' }],
  rows: [{ id: '1', amount: 2, region: 'N' }, { id: '2', amount: 4, region: 'S' }],
  rowKey: 'id',
  grandTotalRow: 'inline',
});
grid.rows.count();

// A number column offers every built-in; a text column offers only what makes
// sense — count and the extremes, never sum. This is the list the chooser shows.
const offered = grid.columns.aggregates('amount');   // ['sum','avg','min',...]

// Switch the footer from Sum (6) to Average (3) at runtime.
grid.columns.setTotal('amount', 'avg');
const total = grid.rows.get(grid.rows.count() - 1).totals.amount;

grid.destroy();
return offered.includes('sum') ? 6 : 0;   // sum is offered on a number column

The group subtotals and the grand total can reduce differently. By default one total drives both, and that is unchanged. When a column needs, say, an average per group under a sum of everything, set the two independently with the scope option: setTotal(id, 'avg', { scope: 'group' }) and setTotal(id, 'sum', { scope: 'grand' }). A scope with no override falls back to total, and passing no scope sets the shared total and clears both overrides — so the one-property behaviour is exactly what it was. The same split is declarable on a column as groupTotal / grandTotal, and it persists in saved views alongside total.

Group average under a grand sum, executed

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'amount', type: 'number', total: 'sum' }, { field: 'region' }],
  rows: [
    { id: '1', amount: 2, region: 'N' }, { id: '2', amount: 6, region: 'N' },
    { id: '3', amount: 4, region: 'S' }, { id: '4', amount: 8, region: 'S' },
  ],
  rowKey: 'id',
  grandTotalRow: true,
});
grid.columns.group(['region']);

// Group subtotals average within each region; the grand total sums everything.
grid.columns.setTotal('amount', 'avg', { scope: 'group' });
grid.columns.setTotal('amount', 'sum', { scope: 'grand' });

// The first group subtotal (region N: (2+6)/2 = 4) and the grand row (sum = 20).
let groupSubtotal = null;
for (let i = 0; i < grid.rows.count(); i++) {
  const r = grid.rows.get(i);
  if (r && r.group && !r.grandTotal && groupSubtotal === null) groupSubtotal = r.totals.amount;
}
const grand = grid.rows.get(grid.rows.count() - 1).totals.amount;

grid.destroy();
return (groupSubtotal === 4 && grand === 20)
  ? 'avg groups under sum grand' : `group ${groupSubtotal}, grand ${grand}`;

Sticky group headings

Scrolling inside a group keeps that group's headings pinned above the rows, so the rows on screen always say which group they belong to. Nested groups stack, up to a cap.

Off by default; opt in or set the cap

createGrid(element, { stickyGroupHeaders: true });    // on, up to two
createGrid(element, { stickyGroupHeaders: 3 });       // on, up to three

This could not be position: sticky. The heading row is very often not in the page at all: the grid renders a window of rows, and a heading five hundred rows above the viewport was recycled long ago. So the pinned heading is synthesised from whichever group the top visible row belongs to, which the grid answers by binary search over an index it builds while flattening the rows, the ten-thousandth row of a group costs what the second one does.

The cap exists because each heading costs a row of viewport. A five-level grouping without one would spend a third of the screen describing what the other two thirds contain.

The pinned headings are hidden from assistive technology. Each is a duplicate of a row that is already in the tree, and announcing it again would report a group the reader has not moved to, and add an entry to a row count that virtualisation already makes hard to reconcile.

The same answer is available directly as grid.rows.groupHeadings(index), which returns the enclosing group rows outermost first, for a breadcrumb, or a heading elsewhere on your page.

Working with large data

A million rows in memory is fine: that is what the columnar store is for. Beyond that, or when the data lives behind an API, a source takes over.

SourceFor
memoryThe default. Everything is present; the grid does all the work.
pagedA page at a time from a server that paginates.
remoteBlocks fetched on demand as the user scrolls, with sort and filter pushed to the server.
streamRows arriving over time, a query that streams, a socket. Promotes to memory once complete.

Trimming the memory footprint

By default the store retains the row objects you hand it by reference, so rows.data() returns those exact objects and row === sourceObject holds. The ingest option controls that:

OptionTypeDescription
retainSourcebooleanDefault true. Set false to keep only the packed columns and reconstruct a plain row object from them on demand. rows.data() then returns freshly reconstructed objects — a new object each call — so row === sourceObject and a custom renderer reading row.sourceObject no longer hold, and equality becomes value-based. Cell values are identical either way, so get(), byKey(), value() and values() are unaffected. On its own this drops only the store's reference array, not the objects: the source layer still holds them.
dropSourceRowsbooleanDefault false. Set true to release the caller's row objects from the source layer and the grid config once the store is built, so the packed columns are the only resident copy. This is where the large reduction lives — roughly an order of magnitude at a million rows — because the caller's objects are the dominant term. Implies retainSource: false (the store must reconstruct), so it carries the same identity tradeoff. An impure computed column — a shadow or a rank/positional column — is never stored, so it cannot be served from the columns: it warns once and drops out rather than answering wrongly, and rows.move() is refused. Do not enable it on a grid that sorts, filters, groups or totals on such a column, or that reorders rows in place.

Dropping retained source objects

createGrid(el, {
  columns,
  rowKey: 'id',
  rows,
  // Release the caller's objects entirely; the packed columns are the sole copy.
  ingest: { dropSourceRows: true },
});

With retainSource: false alone the saving is only the store's own copy of the object references, not the objects themselves: the source layer and the grid config still hold the array, so whoever handed the grid its rows keeps them alive. dropSourceRows releases those references too, so once the caller lets go the objects can be collected and the grid becomes the sole holder of the data. Reach for it on a large, read-mostly grid where you can let go of the source array and do not depend on caller identity through rows.data().

A remote source

source: {
  mode: 'remote',
  pageSize: 100,
  async fetch(req) {
    const res = await api.rows({
      offset: req.range.start,
      limit:  req.range.end - req.range.start,
      sort:   req.sort,          // [{ col, dir }, …]
      filters: req.filters,      // the condition tree, as documented above
      quick:  req.quick,
    }, { signal: req.signal });  // a superseded request aborts itself

    return { rows: res.rows, total: res.total };
  },
}

The request also carries groupBy, groupPath, pivotBy, totals and your own context, so a server that can group and aggregate does that work instead of the browser. Blocks are fetched as the viewport reaches them and cached; changing the sort or the filter invalidates the cache and re-queries.

The filters your callback receives is the same condition tree documented above. You are not handed an opaque object to reverse-engineer, it is the published format, and the same shape you would have written by hand.

Querying an engine directly

A remote source hands you the whole request and leaves the translation to you. A pushdown adapter inverts that: you declare what your engine can answer, and the grid works out what to send and finishes the rest itself.

An OData endpoint, with nothing to write

import { createPushdownSource, odataAdapter } from 'lattice-grid';

createGrid(el, {
  columns,
  rowKey: 'id',
  source: createPushdownSource({
    adapter: odataAdapter({ url: 'https://services.odata.org/V4/Northwind/Northwind.svc/Orders' }),
    pageSize: 100,
  }),
});

Five adapters ship. odataAdapter writes $filter, $orderby, $top and $skip, and follows @odata.nextLink when the server pages on its own terms. restAdapter covers an ordinary JSON endpoint whose parameter names are yours to give. dfqlAdapter speaks DemandFlow's query API. duckdbAdapter takes a live DuckDB connection. graphqlAdapter POSTs a GraphQL operation: because GraphQL has no fixed query semantics, you supply a buildQuery that turns the plan into { query, variables } and a parseResponse that reads data back into rows and total, with defaults for an offset/limit list and a Relay cursor connection. The default pushes only the window and the total, so anything the schema cannot answer stays with the grid.

Declaring what an engine can do

No real engine answers the whole query. An adapter says what it can take, and everything undeclared stays with the grid.

An endpoint that pages and sorts, but does not filter

restAdapter({
  url: '/api/readings',
  params: { offset: 'from', limit: 'size', sort: 'orderBy', order: 'dir' },
  capabilities: { sort: 'single', range: true, total: true },
})
CapabilityValuesMeans
filterfalse | 'term' | 'flat' | 'tree'Nothing, one field and term, a flat conjunction, or a full condition tree.
operatorsstring[]Which comparisons the engine genuinely applies. Declare only those it does.
sortfalse | 'single' | 'multi'No ordering, one column, or several.
quickbooleanWhether a free-text search across columns can be pushed.
rangebooleanWhether the engine can return a window rather than the whole result.
totalbooleanWhether it can report how many rows matched.
groupbooleanWhether it can group and aggregate.

Everything is off unless declared. An adapter that declares nothing still works: the grid fetches and does all the work itself. That is the safe direction to be wrong in. Declaring an operator the engine does not really apply is the unsafe one, because the grid will trust it and stop checking.

Splitting a filter is not symmetric. An and group narrows with each condition, so the supported conjuncts go to the engine and the rest stay behind: the engine returns a superset and the grid narrows it. An or group widens with each branch, so pushing only the supported branches would return fewer rows than the filter allows, and the grid cannot recover rows that were never fetched. A disjunction that is not fully supported therefore stays whole on the client. The same asymmetry governs column pruning in the facet path.

Residual work needs the whole result. When anything is left over, the source stops asking for windows and asks for everything, applies the remainder, and pages from what it holds. Filtering a window on the client is not a slower route to the right answer, it is a fast route to a wrong one: the rows that belong on page one may sit on page nine, and the total is whatever the engine happened to count.

Seeing what was pushed

The split is reported rather than hidden, which is the difference between a slow query you can diagnose and a slow query you cannot.

Asking after the last request

const source = createPushdownSource({ adapter });
createGrid(el, { columns, rowKey: 'id', source });

const plan = source.lastPlan();
plan.pushed;      // the query the adapter was given
plan.residual;    // { filters, sort, quick } the grid applied after
plan.unpushed;    // ['filter'], the parts that stayed behind
plan.needsAll;    // true when the whole result had to be fetched
plan.full;        // true when fullDataset forced it, not just residual work

The grid also warns once, naming the predicate that could not be pushed, because the fix is usually a better adapter rather than a bigger machine. It warns again if an adapter reports a total larger than the rows it returned while residual work is outstanding: that combination silently produces wrong answers, and it is worth knowing about.

A full analytical engine, without carrying one

duckdbAdapter takes a connection you created and imports nothing, so the grid can drive DuckDB while this package stays at zero dependencies.

Parquet in the browser, no server

const db = await makeDuckDB();          // yours: @duckdb/duckdb-wasm
const conn = await db.connect();

createGrid(el, {
  columns,
  source: createPushdownSource({
    adapter: duckdbAdapter({
      connection: conn,
      from: "read_parquet('readings.parquet')",
    }),
  }),
});

from is any FROM expression, so read_parquet('s3://bucket/*.parquet') is as valid as a table name. Values are bound through prepared statements; a connection without prepare is used only for unfiltered queries, because interpolating a user's filter into SQL is the one thing worse than not filtering at all.

Column and table names are checked against an identifier pattern rather than escaped, and a name that fails is refused. Integers past the safe range are kept as strings instead of being rounded into a plausible lie.

A timestamp or date filter is bound through a typed placeholder — "ts" >= CAST(? AS TIMESTAMP) — because a prepared statement binds an ISO string as VARCHAR and DuckDB will not compare that with a TIMESTAMP, TIMESTAMP WITH TIME ZONE or DATE column. The adapter reads the column types once from the engine (DESCRIBE) and falls back to the grid column's declared type, so both a typed and an untyped grid column over a timestamp filter correctly, including a time window. Send instants ending in Z, as the grid's date filter does: the cast is the engine's, and its zone rules apply (see the reference for the TIMESTAMPTZ and naive-string cases).

demo/duckdb.html runs this against a Parquet file of several million readings with no server involved, including a time-window filter on its TIMESTAMP column.

Whether a Parquet file streams or downloads whole

duckdbAdapter writes SQL and hands it to the connection you built; it never opens the file itself, so it has no say in whether read_parquet(...) reads the whole thing or only the bytes a query needs. That is decided by DuckDB's own configuration and by the HTTP server the file is served from.

Enable range reads before the first query

const db = await makeDuckDB();          // yours: @duckdb/duckdb-wasm
const conn = await db.connect();

// Before the first read_parquet(...) call, on DuckDB-Wasm 1.32 and later:
await conn.query('LOAD httpfs;');

createGrid(el, {
  columns,
  source: createPushdownSource({
    adapter: duckdbAdapter({
      connection: conn,
      from: "read_parquet('https://example.com/readings.parquet')",
    }),
  }),
});

Measured against a 1.5 MB Parquet file on GitHub Pages (BACKLOG-0001324): without LOAD httpfs;, DuckDB-Wasm 1.32.0's default HTTP path issued zero Range requests and read the whole file — 100% — for every query, chip filters included. With it loaded on the connection first, the same query issued 25 Range requests and read 30% of the file. DuckDB-Wasm 1.29.0 read 119% of the file on the same test (its ranges overlapped), so treat the exact percentages as a version's behaviour, not a fixed promise — re-measure against the DuckDB build you ship.

The file host has to cooperate too: it must answer HEAD, advertise Accept-Ranges: bytes, and answer a range request with 206 Partial Content and a Content-Range header (GitHub Pages does all three). Cross- origin, its CORS policy must also expose Content-Range and Content-Length, or the browser cannot read them back. Any of that missing, and DuckDB falls back to a full read with nothing said.

A file registered with db.registerFileBuffer(...) is always downloaded whole, whatever version is loaded or however the host answers: a buffer has already been fetched in full before DuckDB ever opens it, so there is nothing left to range-read. There is no force_download option — it does not exist in DuckDB-Wasm; registering a buffer is the way to force a whole download.

Whole-dataset statistics over a remote source

A windowed source reduces a total or statistic over the rows it has loaded, not the whole matching set — a footer median of the 200 rows on screen, which is wrong and looks right. fullDataset makes the source hold the entire matching set client-side so those figures are computed over everything.

Correct footer figures over a REST or DuckDB source

const source = createPushdownSource({
  adapter: restAdapter({ url: '/api/trades' }),
  fullDataset: {
    enabled: true,            // hold the whole matching set, once per query
    maxRows: 1_000_000,        // refuse (visible error) past this
    maxBytesEstimate: 512 * 1024 * 1024,
  },
});

It is off by default and reuses the same whole-result path that residual work already takes: the flag ORs into needsAll, so once the set is held it is ordinary in-memory data and the grid's existing total and statistics kernels reduce over all of it, with no per-stat change. When it is active and within the limits, the whole matching set is covered, so the windowed-statistic warning (BACKLOG-0000731) stays silent — the figure is now honestly whole-dataset.

It is refused loudly, never truncated. A matching set past maxRows or maxBytesEstimate is thrown and surfaced as a source:error with no rows shown, rather than held as a fraction and presented as the whole. A fraction shown as the whole is exactly the silent wrong answer this feature exists to remove, so it is never how the feature fails. For a restAdapter, which cannot compute, this is the only route to a correct whole-dataset statistic.

Refusing a partial result over residual work

When residual work has to run in the browser, the source asks the adapter for the whole matching set and pages from what it holds. An adapter that answers with a page of that result — it paged when told not to — leaves the client-side filter or sort running over the wrong rows: the ones that belong on page one may be in the fraction never fetched. A page presented as the full filtered set is a wrong answer, not a slow one.

Refused by default; opt in only when you knowingly accept it

// Default: a shortfall under residual work throws, surfaced as source:error.
const strict = createPushdownSource({ adapter });

// Knowing escape hatch: keep the old warn-once-and-proceed behaviour.
const lax = createPushdownSource({ adapter, allowPartialResults: true });

Off by default, refused loudly. Filtering or sorting a fraction of the result does not lose rows quietly, it returns the wrong rows, so the default throws rather than warns — nothing fails silently (§8) means a wrong answer is never preferred to a visible failure. The fix is an adapter that follows the engine's own paging, or holding the data in memory. allowPartialResults: true is the deliberate opt-out — a caller who accepts the permissive behaviour keeps the warn-once path — and it is never the default. It does not touch the fullDataset memory guard or the no-residual short-return warning, both of which stand regardless.

Running a host predicate over a pushdown source

A where predicate is your code — whether this user may see the row, whether you hold a rate for its currency. No engine can evaluate it, so a pushdown source can only honour one by fetching every matching row and filtering here. whereRowLimit is the ceiling on doing that, because past a point it is no longer a filter, it is a full download.

The predicate runs under the limit; past it the source refuses and says so

const source = createPushdownSource({
  adapter: duckdbAdapter({ connection, from: 'trades' }),
  whereRowLimit: 50_000,       // the default: run the predicate up to this many matching rows
});

grid.filters.where('visibleToMe', (row) => row.owner === me);

Why a limit at all. A pushdown source exists so a host does not download ten million rows. Honouring a twinless predicate means holding the whole matching set, so wiring it in without a gate would let one filter function silently convert a windowed grid into a full download — trading one silent surprise for another. Under the limit the predicate really runs and the counts are whole-dataset counts; at or past it the predicate is not applied, the rows it would exclude stay on screen, and one warning names the adapter, the matching-set size, the limit and the way out.

No row total counts as over the limit. The only way to learn the size from an adapter that cannot count is to fetch the set — which is the download being guarded against — so the source refuses rather than guesses its way into it.

A refusal costs nothing extra. Where the predicate alone would force the whole result, the size is learned from a bounded probe that is exactly the window fetch the request would otherwise have made, and that result is used as that fetch. A host over the limit pays what it paid before the gate existed.

Prefer the twin. A predicate registered with a { condition } twin is pushed to the engine, which narrows the fetch itself — no limit applies, nothing is held here, and it works at any size. Reach for whereRowLimit only when what you are testing genuinely cannot be written as a condition. On a paged or remote source the twin is the only route: those hold a block cache indexed by the server's own ranges and cannot run a host function at all, and the grid warns once when you register one without a twin.

Pushing statistics down to the engine

A DuckDB-class engine computes a median or a standard deviation over the whole matching set far faster than pulling every row here to do it. The aggregates config decides, at grid setup, which statistics the engine computes and which the grid does — a design-time developer choice, fixed for the life of the grid, never a runtime toggle and never shown to an end user.

Push the verified-identical stats, keep the rest exact

const source = createPushdownSource({
  adapter: duckdbAdapter({ connection: conn, from: 'trades' }),
  aggregates: {
    // 'engine' pushes everything expressible; 'engine-if-identical' pushes only
    // the stats whose engine result is verified identical to the grid kernel;
    // 'client' (the default when absent) computes everything here.
    default: 'engine-if-identical',
    overrides: { mode: 'client' },   // I want the grid's null-when-distinct mode
  },
});

Every statistic is classified IDENTICAL (the engine's result equals the grid's own kernel, verified against it on the same data) or MAY-DIFFER (the engine computes it by a method that can differ from the grid's definition — mode returns a value where the grid returns null). The classification drives the docs and build-time provenance, not whether a stat is pushed: that is your choice. weightedQuantile is the one genuine fallback, always client-side, because the engine cannot express the grid's midpoint convention. The published table of every stat, its class and its SQL is generated from one map (STAT_PUSHDOWN) so it cannot drift.

No mixed provenance. An engine figure and a client figure never appear in one result set. Aggregates are pushed only when the filter is fully pushed; a residual filter the engine could not apply forces every aggregate client-side, because an engine number computed over a superset beside a client number over the real set would be wrong-but-plausible. source.lastPlan().aggregates reports, per stat, whether the engine or the grid computed it and the class it was assigned — inspection during the build, not a per-figure runtime marker.

Grids built from other grids

A derived grid takes its rows from another grid rather than from a load: grouped and aggregated, unnested, filtered, ranked or profiled. It has its own element and its own columns, and it follows its source live.

A summary panel beside the detail grid

const detail = createGrid(left, { columns, rowKey: 'id', rows });

createGrid(right, {
  columns: [
    { id: 'region', header: 'Region' },
    { id: 'total',  header: 'Capacity', type: 'number' },
    { id: 'n',      header: 'Sites',    type: 'number' },
  ],
  source: {
    mode: 'derived',
    from: detail,
    groupBy: 'region',
    select: {
      total: { of: 'capacity', fn: 'sum' },
      n:     { fn: 'count' },
    },
    sort: [{ col: 'total', dir: 'desc' }],
  },
});

follow chooses which of the source's rows are read: filtered by default, or all, selected or grouped. The pipeline runs unnest, then join, then where, then bucket and groupBy, then select, then sort and limit, so a condition or a total can read a field that an earlier stage produced.

A change is patched, not re-derived. When a row changes in the source, the derived grid updates the groups that row belongs to rather than rebuilding the lot. Five hundred updates against a two hundred thousand row source cost under 300 ms in total. Set refresh to live, manual or a number of milliseconds to change the coalescing; idle is the default and settles to a frame.

manual means the host says when, and rows.load() is how it says it. A manual derived grid never re-derives on its own: the source can filter, edit and tick underneath it and the panel keeps showing what it last derived. Call rows.load() on the derived grid, with no argument, and it re-reads its from there and then and replaces its rows; call it again whenever you want the next reading. A derived grid takes its rows from from, so anything passed to load is not used. The derived grid's own sort and filters stay as they were. Press it as often as you like — the source is left exactly as one press leaves it, and destroying the panel leaves nothing of it attached there.

Derived grids are read-only. There is one copy of the data and it lives in the source. Write there and the derived grid follows: an edit through the source's editing API (edit.setCells, an inline commit, a fill, a paste, an undo, a redo or an optimistic rollback) is announced once per batch, after the per-cell cell:changed events, and the derived grid re-derives once for the batch. A grouped derivation patches the groups the edited rows belong to; a derivation with a where, an unnest, a producer, or an edit to a column the source filters on re-reads in full, at the cost measured above. Use idle when a derived child follows an editable grid. Under live the derivation runs synchronously inside the edit call, so a keystroke's commit on a 200k-row source pays the whole re-read before the editor closes; idle defers it to the next frame and folds a burst of edits into one.

The key comes for free. A derived grid keys on __key, which the source writes onto every row it produces: the group value, the profiled column, or the source row's own key when nothing is grouped. Set rowKey only to override it.

A frozen panel, refreshed on a button press, executed

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const detail = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'region' }, { field: 'capacity', type: 'number' }],
  rows: Array.from({ length: 20 }, (_, i) => ({ id: i, region: i % 2 ? 'North' : 'South', capacity: i })),
});
detail.rows.count();

// What the detail grid is listening with before any panel is attached.
const listening = () => Object.values(detail.diagnostics.events()).reduce((a, b) => a + b, 0);
const alone = listening();

// A summary that derives once, then waits to be told.
const panel = createHeadlessGrid({
  columns: [{ field: 'region' }, { field: 'sites', type: 'number' }],
  source: { mode: 'derived', from: detail, groupBy: 'region',
    select: { sites: { fn: 'count' } }, refresh: 'manual' },
});

// The Refresh button. In a page this is your <button>; here any EventTarget will do.
const button = new EventTarget();
button.addEventListener('click', () => panel.rows.load());   // no argument

const sites = () => {
  let n = 0;
  panel.rows.forEach((row) => { n += panel.rows.value(row.key, 'sites'); });
  return n;
};
// Longer than any automatic refresh would take to land, so "frozen" is a finding.
const settle = () => new Promise((done) => setTimeout(done, 50));

const attached = listening();
const seen = [sites()];                                   // 20
detail.filters.set({ col: 'capacity', op: 'lt', value: 10 });
await settle(); seen.push(sites());                      // 20: the source moved, the panel did not
button.dispatchEvent(new Event('click'));
seen.push(sites());                                       // 10: re-derived on the press
detail.filters.set({ col: 'capacity', op: 'lt', value: 5 });
await settle(); seen.push(sites());                      // 10: frozen again
button.dispatchEvent(new Event('click'));
seen.push(sites());                                       // 5

// Pressing it any number of times leaves the detail grid as one press does,
// and destroying the panel leaves nothing of it behind there.
const steady = listening() === attached ? 'steady' : 'grew';
panel.destroy();
const released = listening() === alone ? 'released' : 'left behind';
detail.destroy();
return `${seen.join(' ')}; ${steady}; ${released}`;

Other shapes

OptionDoes
unnestExpands an array property, one row per element, before anything else runs.
bucketRounds a date column down to a day, week, month, quarter or year and groups on that.
limitPerApplies limit within each value of a column rather than overall: a top three per region.
cumulativeKeeps rows until their running share of the total reaches a fraction: the Pareto head.
profileOne row per column with the statistics as columns, or one row per statistic with orient: 'metrics'.

Combining several grids into one

A join matches two grids on a shared key. Some questions have no key to share at all — "the worst performers across two regional datasets" when the two regions use unrelated ids — and for those, from takes an array of sources instead of one grid: every source is read, concatenated in the order you declared them, and only then does the rest of the pipeline run, once, over the combined set.

Worst performers, two unrelated datasets

createGrid(right, {
  columns: [{ id: 'title' }, { id: 'severity', type: 'number' }, { id: '__source' }],
  source: {
    mode: 'derived',
    from: [
      { grid: eastIncidents, label: 'east' },
      { grid: westIncidents, label: 'west', map: (row) => ({ title: row.name, severity: row.rating }) },
    ],
    sort: [{ col: 'severity', dir: 'desc' }],
    limit: 10,
  },
});

label defaults to the source's position in the array ('0', '1', …), and follow is independent per source — filtering one narrows only its own contribution, exactly as a lone from follows its grid today. A source with differently-named fields uses map to project them into a common shape before it joins the rest.

__source is not optional. Every combined row carries it — the entry's label, or its index when unlabelled — and it is an ordinary field to where, groupBy and select. Leaving it out would mean a combined list that cannot say where any row came from, which defeats most of the reason to combine several sources in the first place.

The union of fields, never a merge. A field only one source has is undefined on the others' rows, not fabricated and not type-coerced — two sources disagreeing about what a field means is yours to resolve with map, not something the union guesses at. And there is no dedup: two sources reporting the same fact both appear as separate rows. There is no UNION-vs-UNION-ALL distinction to draw; reach for join when rows should be matched on a key rather than stacked.

The key is namespaced, only where it needs to be. Two sources can easily share row identifiers, so the derived __key is qualified by the source tag when nothing is grouped. Grouped, __key is the group value exactly as it always has been, and rows from different sources landing in the same group is what grouping a union is for, not a collision.

An empty source is fine; a broken one is loud. A source with no rows contributes nothing. A source that throws while being read is named in a console warning and skipped for that pass — a silently missing source would make "worst across both" quietly wrong, so it is reported rather than swallowed.

A cycle is refused when the source is built. If a union's sources include the grid being derived, directly or through a chain of other derived grids, it is refused up front, naming the offending source, rather than being recursed into.

A union never patches — know the cost before you reach for one at scale. A lone from maintains its grouping incrementally: an edit that names the rows it touched re-reduces only the groups those rows belong to. A union does not do this for any of its sources — every change on any parent re-reads and re-derives the whole combined set from scratch. On a synthetic 200,000-row union across four sources, one row changed on one parent cost on the order of 800 ms–3 s (machine-dependent; run node bench/union-parents.mjs against your own shape), against a few milliseconds for the equivalent patched change on a lone from over the same row count. And because a union is watched by as many independent change streams as it has sources, that full-rescan cost is paid once per source that moves, not once per union — four active parents each firing their own updates pay it four times over. Below a few tens of thousands of combined rows this is unlikely to matter; above that, or with several sources each updating on their own live feed, budget for it, keep refresh away from every tick (idle, or a debounce), and prefer fewer, larger sources over many small ones where the shape of your data allows it.

Not supported alongside a union. crossFilter has no single target once there is more than one parent; profile and statistics reduce one grid's own columns. All three are refused with a warning rather than guessed at, and the top-level follow is ignored in favour of each source's own.

The relational statistics, as rows

One grid is the data; a second is the analysis of it. statistics projects the figures that need two or more columns — or a second grid — into rows you can sort, filter, chart and export like any others.

You probably do not need it for a single-column statistic. Those already have a route: a derived select reduces a group with any kernel the totals row uses, and that table is a superset of the statistics one. select: { p95: { of: 'amount', fn: 'p95' } } works today, and so do median, stddev, gini, iqr, entropy, trimmedMean and the rest. statistics is for what select structurally cannot reach.

Say whether the figure is approximate. Every statistics row carries n, the rows the figure was computed over — but n alone cannot tell you whether that was all of them. Over a windowed parent it quietly is not: a stream with maxRows: 200 that has had 2,000 rows through it computes over the 200 it is holding, and the parent's rows.count(), rows.matchCount() and rows.totalCount() all report 200 as well, so nothing in those numbers says a thing has been left out. Ask the parent grid instead: parent.rows.coverage() returns { covered, total, windowed }, and covered < total, or total === null, means the figure is approximate. For that stream it reads { covered: 200, total: 2000, windowed: true } — the 1,800 rows that have aged out are visible there and nowhere else. total is null, never a guess, when the source genuinely cannot know: a stream still open that has evicted nothing has no idea how many rows are coming. A memory parent reads { covered: n, total: n, windowed: false }, so the check costs nothing to leave in and stays quiet when there is nothing to disclose.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'amount', type: 'number' }],
  rows: [{ id: 1, amount: 10 }, { id: 2, amount: 20 }],
});
const { covered, total, windowed } = grid.rows.coverage();
// A memory grid is exact: the figure covers everything there is.
return covered === total && windowed === false;

Which columns move together

source: {
  mode: 'derived',
  from: trades,
  statistics: { fn: 'correlation', columns: ['price', 'volume', 'spread'] },
  // -> one row per PAIR: { a, b, coefficient, n }
}

Three statistics in this release. Each has one row shape, and the shape is the contract:

Every row says how much it saw. n is the rows the figure covered, and it is on the row because a derived statistic travels: a coefficient exported to CSV or bound to a chart has left every bit of its context behind, and “r = 0.98 over eleven rows” is a different claim from the same number over eleven thousand.

What the row does not tell you: whether the source was windowed. A statistic over a source holding fewer rows than match its filters is computed on the loaded window rather than the whole set. The grid detects that from the source's own counters and says so in a console warning ([lattice] correlation on … computed over N of M matching rows) — and that remains the signal to watch. A derived source cannot reach those counters: a grid's public rows.matchCount() reports the loaded matches, so on a bounded stream evicted to 200 of 2,000 rows it returns 200 and agrees exactly with rows.count(). Rather than ship a flag that could never be true, no such flag is emitted; n says what the figure actually saw and nothing more is claimed.

A terminal producer, not a pipeline stage. A correlation is one row per pair, a series summary one row per metric, a comparison one row per column — none of which is one row per group, so there is no position in unnest → where → bucket → groupBy → select → sort → limit for statistics to occupy. It replaces the pipeline, exactly as profile does. Those keys are now ignored with a warning that names them rather than discarded in silence, for both producers. Sort, filter or limit the derived grid itself, or chain a second derived grid whose from is this one. profile and statistics are mutually exclusive and declaring both is refused, by name, when the source is built.

What a terminal producer costs

No terminal producer patches incrementally — know this before you point one at a live feed. The grouped pipeline maintains its grouping across changes: an edit that names the rows it touched re-reduces only the groups those rows belong to. A producer has no grouping to maintain, so every change on the parent re-derives its whole output. This has always been true of profile and was not previously written down; it is written down here now, and it applies to statistics in the same way.

Measured on a synthetic 200,000-row grid, five changes after a warm-up, one derived grid attached (machine-dependent — run node bench/derived-producers.mjs against your own shape):

Derived shapePer change at 200k rows
grouped, no wherethe patching pipeline~1 ms
profile, one column~20 ms
statistics correlation, 2 columns (1 pair)~5 ms
statistics correlation, 6 columns (15 pairs)~32 ms
statistics series~23 ms
statistics datasetVsDataset, two grids~36 ms
where, no groupBya pipeline shape that also never patches~700 ms

Correlation is quadratic in its column count. It scans the rows once per pair, so N columns cost N·(N−1)/2 passes: six columns is fifteen passes, twenty columns is a hundred and ninety. Correlate the columns you mean rather than every numeric column you have.

The costs of several panels add up. Every derived grid attached to a parent re-derives on the same change, so three analysis panels over one grid cost the sum of the three, not the largest. This is BACKLOG-0001044's known gap — a hidden derived grid still does full read and compute work — with a larger constant behind it; a hidden analysis tab recomputing a correlation matrix on every tick is exactly that cost.

The escape hatch is refresh, and it already exists. 'idle' is the default and coalesces a burst of changes into one derivation on the next frame; a number is a debounce in milliseconds; 'live' derives on every change and is the one to avoid for an expensive analysis over a ticking feed; 'manual' stops automatic derivation entirely, leaving the host to drive the source: call rows.load() on the derived grid, with no argument, whenever the analysis should be brought up to date — from a Refresh button, when its tab is shown, or on a timer of your own. Each call re-derives once, at the cost in the table above; a frozen panel refreshed on a button press is executed above. Below a few tens of thousands of rows none of this matters.

Cross-filtering

A derived panel can filter the grid it summarises. Click a region in the summary and the detail grid narrows to it.

Click to filter, click again to release

source: {
  mode: 'derived',
  from: detail,
  groupBy: 'region',
  select: { total: { of: 'capacity', fn: 'sum' } },
  crossFilter: true,       // or a source column name
}

summary.events.on('rowClick', (e) => summary.crossFilter.toggle(e.key));

crossFilter.set, toggle, clear, get and column make up the API. true filters through whatever the grid groups by; a string names a different source column when the two do not share a name.

A panel does not filter itself. The filter a summary pushes onto its source is excluded when that same summary re-derives. Without that, clicking one region collapses the panel to the single row you just clicked, and there is nothing left to click next. With it, the panel keeps its full set of regions with the chosen one marked, which is what makes a second click possible at all.

Several panels compose. Each pushes its own filter onto the shared source and each excludes only its own, so region and status narrow the detail together while both panels stay navigable.

Joining two grids

Two grids holding their own data, and a third showing where they meet. Both sides stay live.

Bringing an owner's fields across

source: {
  mode: 'derived',
  from: sites,
  join: {
    with: owners,
    on: { left: 'ownerId', right: 'id' },
    type: 'left',
    select: ['name', 'tier'],
    prefix: 'owner',       // owner.name, owner.tier
  },
}
OptionDoes
onOne field name when both sides use it, or { left, right } when they differ.
typeinner keeps only rows that matched; left keeps them all.
selectWhich of the partner's fields to bring across. All of them by default.
prefixRenames the brought-across fields, for when both sides have a name worth keeping.
followWhich of the partner's rows to read: all by default, or filtered.

A left join is usually the one you want. An inner join quietly drops the rows that did not match, and those are often the finding: the site with no owner, the payment with no invoice. left keeps them visible with the partner's fields empty, so the gap is something you can see and sort by rather than something you have to notice is missing.

First match wins. The join is a lookup, not a cross product: a row on the left produces exactly one row out, so a grid of ten thousand rows stays a grid of ten thousand rows and cannot silently multiply.

Both sides are live. A change on either grid updates the join, and it is patched from whichever side changed rather than rebuilt.

Editing

Turning it on

edit: { enabled: true },                 // double-click, the default
edit: { enabled: true, start: 'single' },// single click
editBar: true,                            // a spreadsheet-style input above the grid

columns: [
  { field: 'notes', edit: true },
  { field: 'statusId', edit: { editor: 'select' } },
  { field: 'quality', edit: { editor: 'rating', props: { max: 5, allowHalf: true } } },
  { field: 'circuitId', edit: false },
]

The editor is chosen from the type unless you name one. Enter commits and steps down, Tab commits and steps across, Escape cancels. A validator can refuse a value:

Validation

{ field: 'capacity', edit: {
  validate: (p) => p.value > 0 || 'Capacity must be positive',
}}

Declarative validation

The edit.validate function above is the imperative form. Where the rules are simple and the same across columns, declare them as data on validation instead (BACKLOG-0000956): required, min/max, minLength/maxLength, pattern, oneOf, and a crossField predicate. Each is checked before the write, riding the cancellable beforeEdit event: a failing value cancels the commit, marks the cell with the same accessible invalid state an editor rejection uses, and fires validation:failed. Correcting the value clears the mark and fires validation:cleared. Only a user edit is gated — a host API write is the authority and is never self-vetoed.

Rules as data

columns: [
  { field: 'name', edit: true, validation: { required: true, minLength: 2 } },
  { field: 'age',  type: 'number', edit: true, validation: { min: 0, max: 120 } },
  { field: 'code', edit: true, validation: { pattern: '^[A-Z]{3}$', messages: { pattern: 'Three capitals.' } } },
]

// Why a write was refused, and clearing a mark by hand.
grid.validation.errorFor('r1', 'age');       // { code, message, key, colId } or null
grid.on('validation:failed', (e) => report(e.failures));
grid.on('validation:cleared', () => refreshBanner());

Deleting rows

Set rowDelete: true for the built-in delete gesture: Delete or Backspace on the selected rows, and a "Delete row" item in the cell menu. It works on a memory grid, not only a remote one, and every deletion flows through the cancellable beforeDelete event — so a confirm dialog is a handler that calls e.preventDefault(reason). It is off by default because deleting data on a keystroke is destructive.

Confirm before delete

const grid = createGrid(el, { columns, rows, rowKey: 'id', selection: 'multiple', rowDelete: true });

grid.on('beforeDelete', async (e) => {
  const ok = await confirmDialog(`Delete ${e.rows.length} row(s)?`);
  if (!ok) e.preventDefault('cancelled');   // the rows stay; delete:cancelled fires
});

// The gesture calls this; you can call it too. Defaults to the selection.
grid.edit.deleteRows();

Grid lines and corners

Two settings that change how the grid is drawn rather than what it draws.

Rules between cells, and rounded corners

createGrid(element, {
  columns, rows,
  gridLines: 'both',      // 'horizontal' (default), 'vertical', 'both', 'none'
  cornerRadius: true,      // or a number of pixels, or a CSS length
});

gridLines chooses which rules are drawn between cells. 'horizontal' is the default and is what the grid has always drawn; vertical rules between body cells are additive, so the default is unchanged and nothing moves on upgrade. 'rows' and 'columns' are accepted as aliases. Only the rules between data are affected: the header underline and the seams beside pinned columns are structure rather than decoration, and removing them would make the pinned regions look detached.

cornerRadius rounds the grid's outer corners: true adopts the theme's own radius, a number is a count of pixels, and a string is used as written, so '0 0 8px 8px' rounds only the bottom. The body is clipped to match, so a row scrolling past a rounded corner is cut by it rather than squaring it off.

Zebra striping (opt-in)

createGrid(element, {
  columns, rows,
  stripedRows: true,       // shade alternate data rows; off by default
});

stripedRows shades every other data row. It is strictly opt-in and off by default, so a grid that never mentions it looks exactly as it did on upgrade. Parity is decided by each row's logical index rather than its position in the DOM: rows are virtualised and recycled, so a :nth-child rule would repaint the stripe onto whichever row landed in an odd slot after a scroll, and a logical-index stripe keeps a row shaded consistently across a scroll and across the left-pinned, centre and right-pinned segments of the same row. Group headings, group footers and the grand total are structure rather than data, so they are never striped. The stripe uses the theme's --lattice-surface-alt token, which every palette defines, so dark, high-contrast and terminal are correct without any extra rule, and both selection and hover still win over it.

Vertical alignment

verticalAlign is the vertical counterpart to the per-column align: where align places cell content across the column (start, center, end), verticalAlign places it down the row — 'top', 'middle' or 'bottom'. It is a grid-level default with a per-column override: set it on the grid to align every column, and a column's own verticalAlign (or cell.verticalAlign) wins for that column alone. center and centre are accepted as synonyms for middle, the same leniency align gives the horizontal names.

Omitted, the grid keeps the placement it has always had — content centred in a fixed-height row and top-aligned in an autoHeight row — so a grid that never mentions it is unchanged on upgrade. Where it earns its keep is a tall or autoHeight grid: a wrapped-text column can sit at the top while its single-line neighbours are middle, rather than every value floating in the middle of a tall row. The value beats the auto-height rule, so a column asked to sit middle does so even when the row is stretched to fit a wrapped sibling (BACKLOG-0000989).

A grid default, with one column overriding it

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  verticalAlign: 'middle',                 // the default for every column
  columns: [
    { field: 'name' },                       // follows the default: middle
    { field: 'note', verticalAlign: 'bottom' }, // this column overrides it
  ],
  rows: [{ name: 'a', note: 'b' }],
  rowKey: 'name',
});

// The resolved value the renderer reads for each column.
const a = grid.columns.get('name').verticalAlign;
const b = grid.columns.get('note').verticalAlign;
grid.destroy();
return `${a}, ${b}`;

Rich cell tooltips

A cell.tooltip string becomes the browser's own title. That is one line of plain text, shown on the browser's schedule, styled by the browser, and unreachable with a keyboard — it cannot show a related record, a small chart, a list of validation errors or an edit history. The object form of cell.tooltip declares a tooltip the grid draws itself instead (BACKLOG-0001204): { render, mount, unmount }. The plain-text form is unchanged and still produces a title.

render(params) may return an element, a { title, rows, note } spec the grid renders as text, a { html } wrapper, or a string. A bare string is always text, never markup. That is deliberate and is not a style choice: the most natural tooltip anyone writes is render: (p) => p.value, and a value is row data — so if a bare string were markup, a field holding an onerror attribute would execute while the code that rendered it looked harmless. Markup has to be asked for explicitly, in the source, where review can see it; and what goes through { html } is scrubbed of script the same way allowUnsafeTemplates output is.

mount(el, params) and unmount(el) hold live content. The grid core never imports a module, so a sparkline or a KPI tile is mounted by you, inside mount, from a module bundle your page loaded. unmount runs on every close, so nothing is left running behind a hidden tooltip.

tooltip: { delay, maxWidth } on the grid carries the defaults. delay is the rest before anything is built — 400ms by default, which is what stops a pointer sweeping across the grid from mounting a chart per cell — and maxWidth caps the width (a number is pixels, a string is used as written). Neither switches tooltips on: a column with no cell.tooltip has none.

Keyboard and assistive technology. Focusing a cell shows the same tooltip after the same delay, and the cell carries aria-describedby pointing at it, so the content is announced rather than merely drawn. Any aria-describedby the cell already had — a validation message, for instance — is preserved and restored, not replaced. The tooltip is hoverable and stays open while the pointer rests on it, and Escape dismisses it without moving the pointer (WCAG 2.2 AA, 1.4.13). Escape is consumed only while a tooltip is open, so an editor, a menu or a maximised grid still sees it otherwise.

Pooled rows. The tooltip closes on scroll, and its content is resolved from the DOM at the moment it opens rather than when the pointer arrived. Both follow from the same fact: rows and cells are recycled as the grid scrolls, so a bubble left open would be anchored to a node that has since been handed to a different row. It can therefore never show one row's content over another's.

Always-visible and grid-drawn scrollbars

Native scrollbars are overlay bars on most platforms now: they fade away when the pointer is idle, which reads as a cleaner surface but hides the affordance — a touchpad user has no standing sign that a grid scrolls at all. scrollbars takes three modes. 'auto' (the default) leaves the platform's own behaviour alone, so an existing grid is unchanged on upgrade. 'always' keeps the platform's bar shown whether or not the pointer is over the grid. 'custom' replaces it with a bar the grid draws itself.

The two axes are separate decisions, so the object form controls each on its own: { y: 'always' } pins the vertical bar while the horizontal one stays native, and { x: 'always', y: 'always' } is the same as the bare 'always'. Under 'always' the pinned axis switches to overflow: scroll so its track is present even when the content fits, and the scrollbar is styled as a classic, always-drawn bar rather than a fading overlay (BACKLOG-0000990). The same viewport also suppresses the overscroll rubber-band bounce, so a synchronised grid does not spring at its scroll boundary (BACKLOG-0000991).

Why 'custom' exists, and when to reach for it

'always' pins the native bar, which leaves two things it cannot fix. Its size is still the platform's — on Chrome/macOS a 7 pixel overlay ribbon, small to see and fiddly to grab. And the rules that style it are a WebKit/Blink extension, so Firefox ignores them: 'always' is not the same feature there. 'custom' makes the grid draw the bar, so its thickness, colour, minimum thumb length and hit area are the same in every browser on every platform, and all of them are theme tokens a host can raise. The default is a 12 pixel track with a 32 pixel minimum thumb, which is a comfortably larger target than the platform's own (BACKLOG-0001288).

Scrolling is unchanged. The drawn bars are display and input only: the grid's body still scrolls itself, so the wheel, the trackpad, a finger, the keyboard and scrollToRow behave exactly as they do in the other two modes, with the browser's own momentum and acceleration. The thumb is a readout of the scroll position that happens to be draggable, and it is placed in the same painted frame as the content, so it never trails what is on screen.

Everything a scrollbar does. The thumb's length is the fraction of the content on screen, never shorter than --lattice-scrollbar-thumb-min; dragging it scrolls; pressing the track above or below it pages by one viewport; and with the bar focused the arrows, Page Up/Page Down, Home and End all work. Each bar carries role="scrollbar" with aria-orientation, aria-controls and aria-valuenow, and its accessible name comes from the message catalogue, so it is announced in the grid's own language. It is deliberately not in the page's tab order: the grid is a single tab stop and its own arrow keys already scroll.

Two things to know. The gutter the drawn bar occupies is reserved permanently while the mode is on — the same 12 pixels 'always' reserves, so column widths and columns.fit() are unaffected by the change and nothing moves under the pointer as rows come and go. And no browser lets one axis' native scrollbar be hidden on its own, so setting 'custom' on one axis hides the native bar on both; the grid warns once if the two axes disagree. Set 'custom' on both axes, or on neither.

Theming the drawn bar

TokenDefaultWhat it sets
--lattice-scrollbar-size12pxThe thickness of each bar, and the gutter reserved for it. Raise it for a larger grab target.
--lattice-scrollbar-thumb-min32pxThe shortest the thumb may ever be. Sized strictly in proportion, a very long list gives a thumb of a pixel or two.
--lattice-scrollbar-tracktransparentThe track behind the thumb.
--lattice-scrollbar-thumbvar(--lattice-border-strong)The thumb at rest. Derived from the theme's line colour, so dark, high-contrast and terminal themes follow without declaring anything.
--lattice-scrollbar-thumb-hovervar(--lattice-foreground-muted)The thumb while the pointer is over its bar.
--lattice-scrollbar-thumb-activevar(--lattice-accent)The thumb while it is being dragged, or while the bar has keyboard focus.
--lattice-scrollbar-radiusvar(--lattice-radius-pill)The thumb's corner radius. Set it to 0 for a square thumb.

The three modes, and one axis at a time

// The renderer resolves `scrollbars` to a per-axis mode it stamps on the root.
const { resolveScrollbars } = await import('../packages/dom/src/renderer/renderer.js');

const both = resolveScrollbars('always');        // pins both native bars
const drawn = resolveScrollbars('custom');       // the grid draws both
const onlyY = resolveScrollbars({ y: 'always' }); // pins the vertical bar only
const mixed = resolveScrollbars({ x: 'custom' }); // draws x, leaves y native (and warns)
return `always: ${both.x}/${both.y}; custom: ${drawn.x}/${drawn.y}; `
  + `y-only: ${onlyY.x}/${onlyY.y}; mixed: ${mixed.x}/${mixed.y}`;

A bigger grab target than the platform's

createGrid(host, {
  columns, rows,
  // Always visible, the same in every browser, and drawn by the grid.
  scrollbars: 'custom',
});

/* Wider and darker than the default, in your own stylesheet: */
.lattice {
  --lattice-scrollbar-size: 16px;
  --lattice-scrollbar-thumb-min: 48px;
  --lattice-scrollbar-thumb: #8a9199;
}

Cards, lists and feeds

rowTemplate draws each row with a layout of your own instead of dividing it into columns. A card list, a feed, a search-result list, a message list: any presentation where a record is a small piece of layout rather than a line of cells.

Two shorthands sit on top of it for the shapes that recur. recordCard presents each row as a record card — a form of label/value pairs, one line per column in display order, showing the same text the table shows — for a screen where reading one record matters more than comparing many. gallery presents the rows as a grid of tiles laid out by the same 2-D virtualisation the grid already runs. Both take true to generate the layout from the columns, or an object to size it or supply a template, and both are presentation only: sort, filter, group and the data pipeline are unchanged.

board is the third shape: a kanban of grouped lanes of cards. Group the grid and the top-level group becomes a lane, with every leaf under it a card stacked in that lane — a pipeline by stage, a task list by status, a backlog by owner. Both axes are virtualised, the lanes across and the cards down each, so a board of many long lanes draws only what is on screen. A board card is still a row: it clicks, selects and drags through the grid's own handlers, and masks protected columns exactly as every other card does. Like the others it is presentation only.

pivotView is the fourth shape: the grid drawn as a pivot — a cross-tab matrix. The grid's group dimensions run down the left gutter, its pivot dimensions run across the top, and each totalled column fills a cell with its reduction, with a subtotal down every row, across every column, and the grand total in the corner. Those numbers are the grid's own: every cell is the same aggregate kernel the totals row uses, run over the rows that feed the cell, so a pivot subtotal equals the grid's group total for that set by construction rather than being re-derived — an average subtotal is the average of the rows, never an average of cell averages. Both axes expand and collapse and both are virtualised through the shared row-template layer, so a wide, deep matrix draws only the cells on screen. Clicking a body cell emits pivot:drill with the keys of the contributing rows, the pivot's answer to “what is behind this number”. The collapse state rides in a saved view, and because a matrix cannot be read on a phone, at or below maxWidth the pivot degrades to a card list, exactly as the table does under responsive. Like the others it is presentation only.

The shorthands, and the config a grid reports back

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'name' }, { field: 'owner' }],
  rows: [
    { id: '1', name: 'Alpha', owner: 'Ada' },
    { id: '2', name: 'Beta', owner: 'Ben' },
  ],
  rowKey: 'id',
  // Each row as a labelled record card…
  recordCard: true,
  // …or a gallery of size-driven tiles…
  gallery: { tileWidth: 240 },
  // …or a kanban of grouped lanes. Presentation only.
  board: { laneWidth: 300 },
});

const c = grid.config();
return `recordCard ${c.recordCard === true}, gallery ${c.gallery.tileWidth}px, board ${c.board.laneWidth}px`;

A pivot: group down, pivot across, and the drill event a cell click fires

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'region' }, { field: 'product' }, { field: 'amount', type: 'number', total: 'sum' }],
  rows: [
    { id: '1', region: 'EMEA', product: 'Widget', amount: 10 },
    { id: '2', region: 'APAC', product: 'Gadget', amount: 5 },
  ],
  rowKey: 'id',
  // Draw the grid as a pivot: the group down, the pivot across, a measure per cell.
  pivotView: true,
});
grid.columns.group(['region']);
grid.columns.pivot(['product']);

// A body-cell click drills to its rows through this event.
let drills = 0;
grid.on('pivot:drill', () => { drills += 1; });

// The collapse state a saved view carries.
const state = grid.pivotView.state();

return `pivotView ${grid.config().pivotView === true}, collapsed ${state.rowsCollapsed.length}, drills ${drills}`;

A card list

createGrid(element, {
  columns,                       // still declared: they are the data model
  rows,
  rowKey: 'id',
  rowHeight: 64,
  rowTemplate: '<p class="title">{{data.name}}</p>'
             + '<p class="sub">{{data.owner}} · {{data.stage}}</p>',
});

The template compiles; it does not call back. There is deliberately no "here is a container, build what you like for this row" hook. That shape is easy to offer and would be used to allocate DOM per row, and at that moment the virtualisation stops paying for itself: quietly, and in a way nobody can attribute to a change. A row template is the same declarative string a cell template is: parsed once, built into real DOM the first time an element is used, and afterwards updated by writing text into the few nodes the bindings own. Scrolling ten thousand records through a hundred pooled cards allocates nothing.

Everything underneath is unchanged. Sorting, filtering, grouping, selection, permissions, redaction, saved views, undo, export and the remote source all apply exactly as they do to a table: only the drawing changes. That is the reason to build a card view on a grid rather than beside one.

A card is still a row. It carries the same row identity a table row does, so row:clicked and row:dblclicked fire with the same payload, clicking selects, the context menu opens, and rowReorder works, with the card itself as the drag handle, since there is no cell to put a grip in. None of that is a second implementation; it is the same code that serves a table.

It is announced as a list, not a grid. A card has no columns, so the grid role, which promises columns, gridcell children and a two-dimensional keyboard model: would misdescribe it completely. The layer is a list, each card a listitem carrying its position and the size of the whole set, and the column header is not drawn. role and itemRole override both, for a presentation that is really a listbox.

Collapsing to cards on a narrow screen

A table on a phone is a compromise however it is styled. responsive presents rows as cards when the grid is too narrow to be a table honestly, and returns to a table above the threshold.

One grid, two presentations, chosen by width

createGrid(element, {
  columns, rows, rowKey: 'id',
  toolPanel: true,
  responsive: {
    maxWidth: 640,                // the default
    rowHeight: 64,
    template: '<p class="title">{{cell.name}}</p><p>{{cell.owner}}</p>',
  },
});

Measured on the container, not the viewport. A media query is the obvious way and the wrong one: a grid inside a 400px panel on a large monitor is narrow, and a grid filling a small tablet is not. The grid already watches its own element for size changes, so the same observer answers this.

The state a user built survives the switch. Rotating a phone must not lose the sort, the filters, the selection or the scroll position, and it does not, it is one grid throughout, and only the drawing changes. An open cell editor is closed, since the cell it belonged to stops existing.

Sorting and filtering need a home when there are no column headings to click, and the tool panel is it: set toolPanel: true and its rail stays available in card presentation with the columns and filter panels behind it. Export is unaffected: the columns are still the data model, so a CSV or an Excel file from a collapsed grid holds every column, including ones the card does not show.

A collapsed card takes rowHeight from the responsive block rather than the grid's, because a table row is too short to hold one; 64px by default. The change is announced, and the role tree follows the presentation. presentation:changed fires with 'cards' or 'table'.

Showing what the grid shows

{{cell.column}} is the text the table puts in that cell, the column's own formatter, data type, number and date settings and lookup label, all of it. {{data.field}} is the raw value underneath.

BindingReads
{{cell.value}}£1,250.50, the cell's rendered text
{{data.value}}1250.5, the stored number
{{cell.stage}}Held, a lookup's label
{{data.stage}}2, the lookup's id

Both are wanted, which is why both exist: a card showing a value to a person wants cell, and a template comparing or calculating wants data. A lookup is the case that decides it, a card showing 2 where the table shows Held is not a formatting preference but a plain bug.

A protected column cannot be read raw. Binding {{data.password}} on a secret or redacted column would print the value the column exists to hide, while the table beside it shows dots. Such a binding reads the masked text instead and says once that it did, a card must not become the hole a redaction closes.

Several cards on a line

By default a card takes a line to itself, which is what a feed or a search-result list wants. A gallery wants tiles, and there are two ways to ask for them because they answer different questions.

A fixed count, and a size that flows

rowTemplate: { template: CARD, cardsPerRow: 3 }        // always three across
rowTemplate: { template: CARD, maxCardWidth: 260 }    // as many as fit

cardsPerRow is a count, for a layout that must not reflow. maxCardWidth is a ceiling: the grid fits as many whole cards as it can without exceeding it, and they share the remaining space rather than leaving a ragged margin, so 900px at a 200px ceiling is four cards of 225px, and the count changes with the container. gap sets the space between them. Where both are given, cardsPerRow wins, being an instruction rather than a preference.

The scroll height counts lines, not records. Four records on a line means the content is a quarter as tall as the row model alone would make it, and a scrollbar sized per record would be four times too long, the last several screens empty. The tiled layout works out its own window from the scroll position for the same reason: the window it would otherwise be handed counts one record per line and would leave the bottom of the screen bare. Pooling is unaffected; scrolling a tiled gallery reuses its elements exactly as a list does.

Tiles are a fixed height, taken from rowHeight, rowHeight: 'auto' measures a rendered row and cannot describe a line holding several of different heights. Variable-height tiles flowing into the shortest column is a masonry layout, which is a different thing and is not offered.

Row heights work as they do everywhere else, including rowHeight: 'auto', which measures the rendered card: content-driven card heights need no extra configuration. The columns are still declared and still hold the data: they are what sorting, filtering and export operate on, and what the bindings read.

Editing a row on a form

Double-clicking a row opens it in a panel (a right-hand drawer or a centred dialog) with one control per field, a Save and a Cancel. It is the shape almost every application built on a grid ends up wanting, and until now the shape they had to build themselves.

The grid's own columns, in a drawer

createGrid(element, {
  columns, rows, rowKey: 'id',
  editable: true,
  rowForm: true,
});

That is the whole of it for the common case: the form is the row, edited with the same editors, types, formats and lookups the cells use. Where the record has more to it than the grid shows, give a load function, and then say which fields and in what order, because nothing in the grid knows the shape of something it has never seen.

A fuller record, in a dialog

rowForm: {
  mode: 'dialog',                       // 'drawer' is the default
  title: ({ data }) => 'Edit ' + data.name,
  load: ({ key }) => fetch(`/api/orders/${key}`).then((r) => r.json()),
  fields: [
    { field: 'name',  label: 'Name' },
    { field: 'ref',   label: 'Reference' },   // not a column
    { field: 'notes', label: 'Notes', editor: 'textarea' },
    { field: 'score', label: 'Score', editor: 'rating', props: { max: 5 } },
  ],
}

Which editor a field gets

Every editor is available on a form, including your own from the module registry, the form builds its controls through the same call a cell does, so a field gets the same editor, type, formatting and lookup its column would have given it. A field named after a column borrows that column outright and needs nothing further.

A field the grid has never seen (or one you want entered differently from its cell) says so on the field itself. editor names it, and type, props and lookup configure it exactly as they would on a column. Overriding the control does not change where the value goes: a field still writes back only if it maps to a column.

A picker opens when it is asked to. A popup editor, a date, a dropdown, a tree, a colour, a code panel: is its panel: in a cell it opens the moment the cell does, which is right, because the user has just asked to edit that one cell. A form builds every field at once, so on a form the field shows the current value on a control and the panel opens over it when clicked. Choosing puts the panel away again and updates the control.

The panel opens before the record arrives. A click that does nothing for half a second reads as a click that was missed, and the user clicks again. So the panel appears immediately with a loading state and fills in when the record lands.

A failure keeps the panel open and offers a retry inside it. Closing would discard the intent and leave the user nothing to act on but the row they already double-clicked.

And a load that never answers is a failure too. A promise that neither resolves nor rejects is what a dropped request looks like from the page; left alone it spins until the user gives up, which reads as an application that has hung rather than a request that failed. After timeout milliseconds, two seconds unless you say otherwise, the form stops waiting and shows the same message and retry as any other failure. Set timeout: false to wait indefinitely, which is right only where your own loader already has a limit and would rather report that one. A record that turns up after the form gave up on it is discarded rather than dropped into a panel the user may have moved on from.

The fields scroll and the heading and buttons do not, so Save stays reachable on a record with forty fields. If a validator refuses one of them, the form stays open, the field is marked, and it is scrolled into view and focused: on a long form the offending field is otherwise nowhere near the button that was just pressed.

Save collects the changed fields, writes the ones that map to columns, and announces the lot. Where the record actually lives is not something the grid can know, so persisting is yours: a field that came from load and is not a column is reported in unmapped and not written, since inventing a column for it would put data in the grid that the grid was never asked to show. Save is disabled while there is nothing to save.

MemberDoes
form.open(key)Open a row by key. Returns false if there is no such row.
form.close()Close without saving.
form.save()Commit the fields and close. Returns false if a validator refused, or if there is nothing to save.
form.isOpen()Whether the panel is showing.
form:openedFired with { key, row }.
form:savedFired with { key, values, changed, unmapped }.
form:closedFired with { key }.
form:errorFired with { key, error, timedOut } when a load fails or runs out of time.

The form takes the double click. On an editable grid that gesture also opens a cell editor, and the two cannot both own it, a form that quietly did nothing where a cell happened to be editable would be worse than no form. So where rowForm is configured, double-clicking a row opens the form and the cell editor stays reachable by Enter or by typing into the cell. Set trigger: false to leave opening entirely to form.open() and keep double-click for cells.

Putting the form in your own element

A drawer and a dialog both sit over the grid. Give container an element of your own and the form is built there instead, a sidebar beside the grid, a panel below it, a column in a layout you already have. It fills what it is given, so the size and position are yours.

A sidebar the application owns

createGrid(element, {
  columns, rows, rowKey: 'id', editable: true,
  rowForm: { container: '#record-panel' },   // or the element itself, or a function
});

A selector is resolved when the form opens, not when the grid is configured, because a grid is routinely built before the layout around it exists. A container that cannot be found falls back to opening over the grid: better a form in the wrong place than a double-click that appears to do nothing.

A form in your own container is not modal. It sits beside the grid rather than over it, so it takes nothing away: it is announced as a region rather than a dialog, and Tab moves out of it into the rest of your page instead of being trapped. Claiming otherwise would tell a screen reader user the page had gone away when it plainly has not. Escape still closes it, and it still takes focus when it opens.

Over the grid, the panel is a modal dialog: it takes focus when it opens, traps Tab while it is showing, closes on Escape, and returns focus to whatever had it before. Its width can be set with width.

Optimistic writes and rollback

The grid has always written optimistically without calling it that: an edit lands in the model and is painted before anything else happens. What edit.commit adds is durability: whether the write reached your server, and what to put back when it did not.

Nothing changes unless you ask for it. With no commit hook the grid behaves exactly as before: the value is written, history is recorded, cell:changed fires, and there is no pending state to think about. Subscribe to cell:changed, fire your request and ignore the result: that keeps working and costs nothing.

The usual case, the promise is the answer

edit: {
  enabled: true,
  commit: async ({ key, colId, value }) => {
    const res = await fetch(`/api/rows/${key}`, {
      method: 'PATCH',
      body: JSON.stringify({ [colId]: value }),
    });
    if (!res.ok) throw new Error(await res.text());   // throw → rolled back
  },
}

Resolving confirms the write; throwing rolls it back and fires cell:reverted with your error message as reason. A synchronous hook works too: returning normally confirms, throwing reverts.

When the answer arrives elsewhere

edit: {
  enabled: true,
  confirm: 'manual',                       // the return value is ignored
  commit: ({ id, key, colId, value }) => {
    socket.send(JSON.stringify({ id, key, colId, value }));
  },
}

socket.onmessage = (m) => {
  const { id, ok, reason } = JSON.parse(m.data);
  grid.edit.settle(id, ok, reason);
};

The mode is declared, never guessed. A websocket or event-sourced backend acknowledges on a different channel from the one the write went out on, so there is no promise to resolve. confirm: 'manual' says so explicitly. The grid does not infer it from what commit returns, because then a synchronous hook that happens to return nothing would leave every cell pending for ever with nothing in your code that looks wrong. If a write does stay pending, you get a console warning naming the cell: tune the threshold with pendingTimeout.

The states a write moves through

StateMeansEvent
pendingApplied and painted, not yet acknowledged.cell:pending
confirmedThe server accepted it. Nothing is written back.cell:confirmed
revertedThe server refused it; the cell is rolled back.cell:reverted
conflictThe write was accepted but the server row had moved underneath it. Last-write-wins: your value stands and the server's truth is surfaced so you can reconcile it.cell:conflict
supersededA newer edit replaced it while it was in flight.either, with superseded: true

Rollback goes to the last confirmed value, not the previous one. This is the part that is easy to get wrong by hand. Suppose a cell holding 1 is edited to 2, then to 3, then to 4, all before any answer comes back. If the second write fails, restoring “the value before it” would put back 2, a value the server never held, and one the user has since replaced twice. So each cell remembers the newest value a confirmation has actually vouched for, and a write that a later edit has superseded reports its failure without writing anything back. You will see cell:reverted with applied: false for those.

The rejected value travels on the event as rejected, so you can offer a retry rather than losing what the user typed.

Two behaviours worth knowing. An unconfirmed edit goes through the normal pipeline, so if it changes a sorted or filtered column the row moves immediately and moves back if the write fails. And undo of an in-flight edit issues a compensating write, a fresh write back to the previous value, itself tracked: rather than pretending to cancel a request that has already gone out.

Asking what is outstanding

grid.edit.pending();              // [{ id, key, colId, value, before, state, age }]
grid.edit.status('r1', 'cap');    // 'pending' | null

Pending cells are marked with --lattice-pending-background and rolled-back ones flash --lattice-reverted-background; restyle either through the tokens. Both use the same highlight model as everything else, so the marks survive scrolling, sorting and row recycling.

Tree data

Rows that sit under one another rather than in a flat list. Two shapes, and they answer different questions about where the hierarchy lives.

The row names its parent

tree: {
  parentKey: 'parentId',        // a field, or a function of the row
  label: 'name',                // what the tree column shows
  orphans: 'Unassigned',        // or 'root', the default
}

The row carries its own ancestry

tree: { path: (row) => row.hierarchy }   // ['EMEA', 'UK', 'Colchester']

Parent-reference is what a join or a document store produces. Every node is a real row. A row whose parent is not in the data (filtered away, not loaded, or simply wrong) is an orphan: it goes to the root by default, or into a named bucket. It is never dropped, because hiding a record over a bad reference loses data the user can see in a flat view.

Path-based rows describe their own place, so intermediate levels may have no row at all: EMEA/UK/Colchester with no EMEA/UK row still needs a UK node to sit under. Those are synthesised, and render as group rows: a heading over the rows beneath it with no record of its own. A real row arriving later for a level already synthesised fills that node rather than appearing beside it.

A parent cycle is not a hierarchy and cannot be walked. It is reported once and cut, with the rows shown at the root: wrong place beats vanished.

The grid generates a tree column to carry the expander and the indent, on the same terms as the auto-group, selection and detail columns: pinned to the start, and absent from columns.visible(), saved views, exports and the tool panel. Its text comes from tree.label; without one it falls back to your first visible column, which then appears twice until you hide it, the grid does not remove a column you did not ask it to remove.

Loading a branch on demand

Children fetched when the node is opened

tree: {
  parentKey: 'parentId',
  hasChildren: (data) => data.childCount > 0,
  loadChildren: (row, signal) => api.children(row.data.id, { signal }),
}

hasChildren lets a row declare children it does not hold, so the expander is there before anything is fetched: without it there is nothing to click and the branch can never load. Such a node reads as closed even though tree nodes are otherwise expanded by default, because an open branch with nothing under it leaves no gesture to load it.

The rows that arrive are added to the data set, so the hierarchy rebuilds through the same pipeline as everything else and the new rows sort, filter and export like any other. A branch is fetched once however often it is toggled; closing it before the rows arrive aborts the request through the signal. A rejection is reported and leaves the branch unloaded, so reopening tries again rather than showing an empty node for good. tree:loading, tree:loaded and tree:loadFailed are on the event bus.

Expansion is the same state group expansion uses, so rows.expand, rows.collapse, expandAll, collapseAll and saved views all work on it. A collapsed branch is skipped rather than hidden, so it costs nothing. Grouping and tree together is not a combination: the grouping wins and says so once, because two expanders in one row would be two hierarchies claiming the same rows.

Master-detail

A master row expands to reveal a detail region: by default a nested grid over whatever detail.rows(row) returns, which may be a promise. The grid adds an expander column while the feature is on, on the same terms as the auto-group and selection columns: pinned to the start, and absent from columns.visible(), saved views, exports and the tool panel.

Inline, a detail row beneath its master

detail: {
  rows: (row) => api.lines(row.data.id),   // array or promise
  config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
  height: 240,
  isMaster: (data) => data.lineCount > 0, // default: every data row
  cacheLimit: 10,
}

grid.detail.toggle(key);
grid.detail.keys();                       // every open master
grid.detail.closeAll();

The detail is a real display row: virtualised, height-managed, and pushing the rows below it down. Any number of masters can be open at once. height takes a number or a function of the row.

A detail pane instead of a detail row

Targeted, the list-and-pane layout

detail: {
  target: '#detail-pane',                 // a selector or an element
  rows: (row) => api.lines(row.data.id),
  config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
}

grid.detail.active();                     // the open master, or null
grid.detail.placement();                  // 'inline' | 'target' | null

With target the detail renders into an element you own rather than into a row. No detail row is created, so the grid's row count does not change when a master opens, and nothing about the list's geometry moves.

Exactly one master is open at a time in this placement. One element cannot show two details, and stacking them turns a fixed-height pane into a scrolling list of grids with no rule for how tall each should be. Expanding a second master closes the first; height is ignored, because the pane's height is yours.

A target selector that matches no element is reported once and leaves the details unshown: silence there is indistinguishable from a detail that fails to open, and the cause is not visible from the grid.

The control changes with the placement, because the gesture does. Inline it is a chevron that turns down when the row expands, carrying aria-expanded: the ordinary disclosure pattern. Targeted, nothing expands: the row is being chosen and its detail appears elsewhere, so the control becomes the “opens elsewhere” glyph and a toggle (aria-pressed) rather than a disclosure. A chevron there would promise an expansion that never comes. The chosen row is marked with lat-row--detail-active and aria-current, since with the detail off to the side nothing else in the grid says which record the pane belongs to.

An editable detail

The detail is a whole grid, so it edits like one: put edit in detail.config and its cells are editable. The rows it shows are usually a sub-array of the master's own record, so an edit there changes the master's data directly; there is nothing to copy back.

One listener on the master covers every detail

detail: {
  rows: (row) => row.data.ports,          // a sub-array of the record
  config: {
    columns: [{ field: 'port' }, { field: 'vlan', type: 'number', edit: true }],
    edit: { enabled: true },
  },
}

grid.on('detail:cell:changed', (e) => {
  e.masterKey;   // 'C1', the row the detail belongs to
  e.path;        // 'ports.1.vlan': where it lands on the master's record
  e.value;       // 999
  e.oldValue;    // 101
});

A nested grid is created by the grid, not by you, so its own events would otherwise be out of reach. The edit lifecycle (detail:edit:started, detail:edit:stopped, detail:cell:changed) is re-emitted on the master, tagged with the master it came from. You never have to hold the nested grid to hear about an edit inside it.

path is the dot notation from the master's record to the value that changed, so a host can persist a detail edit against the master and never think about the nested grid at all. It is worked out by identity: rows(row) usually returns an array that is already a property of the record, and that property is the prefix. A detail fetched from a server is not part of the master's record, so its path is null, set detail.path to name it yourself when you want one anyway.

For anything the forwarded events do not cover, detail.onCreate(grid, masterRow) hands you the nested grid itself as it is built.

SettingWhat it does
rows(row)The nested grid's rows. May return a promise; the region is built empty and loaded when it settles, so an empty detail and a pending one do not look the same.
configThe nested grid's configuration. It inherits the licence and module registry from the master by construction.
render(container, row)Draw the region yourself instead of a nested grid. Return anything with a destroy() method.
isMaster(data, row)Which rows can expand. Group rows and detail rows never can.
targetA selector or element to render the detail into. Omit for inline. One master open at a time.
onCreate(grid, row)The nested grid, as it is created.
pathThe property of the master's record the detail rows live on, when it cannot be worked out by identity.
cacheLimitHow many regions are retained after closing, so collapse and re-expand does not refetch. Open regions are never evicted, whatever the limit. 0 destroys on collapse. Default 10.

Selection and ranges

Row selection and cell ranges are separate answers to separate questions, which records versus which values. Dragging across cells does not tick row checkboxes, and selecting rows does not build a range.

Cell ranges are on by default; row selection is not. Set selection: 'single' or 'multiple' to turn rows on: until you do, selection.set() accepts the call and keys() comes back empty.

Both

selection: 'multiple'                  // rows are off until you ask

// Rows, which records
grid.selection.set(['r1', 'r2']);
grid.selection.keys();                 // the selected row keys
grid.selection.rows();                 // the row wrappers
grid.selection.all();                  // select everything that passes the filter
grid.selection.clear();

// Cells, which values
grid.selection.setRange({ startRow: 0, endRow: 9, columns: ['cap', 'margin'] });
grid.selection.cells();                // [{ key, colId }, …]
grid.selection.summary();              // count, sum, min, max, avg over the range
grid.selection.clearRange();

// Several blocks at once: ctrl-click, Ctrl+Shift+Arrow, or from the API
grid.selection.addRange({ startRow: 20, endRow: 29, columns: ['cap'] });
grid.selection.extendRange(34, 'cap');  // grows the block just added

The checkbox column

A column of checkboxes, with select-all in the header

selection: { mode: 'multiple', checkbox: true, headerCheckbox: true }

checkbox: true adds a narrow column of checkboxes at the start of every row, pinned so it does not scroll away. headerCheckbox: true puts a select-all box in its heading, which shows three states: unchecked when nothing is selected, checked when everything is, and the native indeterminate mark when some are. Clicking it selects everything when it is not already full, and clears when it is: including from the indeterminate state, where the intent is "select the rest".

"Everything" means every row the filter currently shows, not every row loaded. The column is generated rather than declared: it does not appear in columns.visible(), in a saved view, in an export or in the tool panel's visibility list, and it disappears when the option is turned off. grid.selection.headerState() returns the same tri-state the header shows, for building your own control.

Selected rows carry aria-selected and the class lat-row--selected, which the theme styles.

A row with its own click action

selection: { mode: 'multiple', checkbox: true, checkboxOnly: true }

checkboxOnly: true restricts row selection to the checkbox column: clicking the checkbox selects or deselects the row, and clicking anywhere else in the row does neither. This is for a host that binds its own action — typically opening a record's detail view — to a plain click on the row: without it, that click also selects the row, and a bulk action run afterwards operates on rows the user never chose to select. The same restriction applies to the keyboard: Space still toggles selection while focus is on the checkbox cell, and does nothing elsewhere. Cell ranges and the fill handle are unaffected either way. Off by default, so a plain click still selects a row exactly as it always has.

checkboxOnly only narrows which gesture may change selection; it does not grant selection where mode: 'none' has already refused it, and it composes normally with mode: 'single' — the checkbox remains the only way to change which one row is selected.

With mode: 'single', checking a row's box selects that row and replaces whichever one was selected before; unchecking the selected row clears the selection. grid.selection.set(keys) itself keeps only the first key of whatever array it is given when mode is 'single' — that contract is unchanged (BACKLOG-0001233) — so the checkbox column never builds a two-key array to hand it; it sets the one key it just toggled.

Ctrl+Shift+Arrow is the keyboard form of ctrl-dragging. The first press opens a block at the focused cell without discarding what is already selected; the presses after it extend that block, so holding the chord draws one rectangle rather than a new one per key repeat. A plain Shift+Arrow goes back to extending a single block, and clicking anywhere ends the run.

What multiple ranges do and do not support. Painting, cells() and the status-bar summary all work over the union of the selected blocks. Copy is narrower, because tab-separated text is a rectangle: blocks stacked over the same columns, or joined over the same rows, copy fine, a diagonal pair has no rectangular form, so rangeText() returns '' and clipboard:copy reports reason: 'discontiguous' rather than emitting misaligned rows. Filling is narrower still: with more than one block selected there is no single source to extend, so the fill handle is hidden and fillTo and fillDown decline.

Turning it off

selection: 'none'                  // neither
selection: { ranges: false }       // rows only, no drag-select

Filling a series

Dragging the fill handle continues what it can recognise rather than repeating the block. Detection is per column, so dragging three columns down continues three independent series.

What is recognised

1, 2, 3        // → 4, 5, 6      constant difference, two or more values
5, 10, 15      // → 20, 25       any step, including negative and fractional
2026-01-01 …   // → the next day, week or whatever the gap is
15 Jan, 15 Feb // → 15 Mar        whole months, holding the day of month
31 Jan, 28 Feb // → 31 Mar, 30 Apr  month ends stay on the month end
'x', 'y'       // → x, y, x, y   nothing recognised, so the block repeats
7              // → 7, 7, 7      one value is a copy, not a series

Months step as months. 15 January to 15 February is thirty-one days, and continuing in days would land on 18 March and drift further every step. A month step holds the date the user picked, and clamps where the month is short: 31 January plus a month is 28 February, not 3 March.

Month ends are their own case. 31 Jan, 28 Feb is a month series whose second value has already been clamped, so the two share no day of month and the day-preserving rule cannot see it. Where every source value is the last day of its own month, the fill stays on the month end: 31 March, 30 April, 31 May, and 29 February in a leap year. Values that share a day of month keep the day-preserving answer, so 30 Apr, 30 Jun still gives 30 August rather than the 31st.

One limit worth knowing. Filling upwards is not supported; the handle extends downwards only.

Supplying your own series

selection: {
  fill: ({ source, target, direction }) => target.map((t, i) => nextCode(source, i)),
}

A domain series (order codes, fiscal periods, seat numbers) is not something the grid can infer, so selection.fill takes precedence when you supply it. It must return one value per target row; anything else is ignored in favour of the built-in detection, rather than being partly applied.

Holding live updates

A pause button for incoming data. Changes are held and merged while paused, applied when play is pressed, and counted throughout, so the coalescing that makes a live grid fast is finally visible.

Pause, play, and the counters

grid.updates.pause();
grid.updates.resume();          // apply everything held
grid.updates.flush();           // apply what is waiting, stay paused

grid.updates.stats();
// { paused, pending, queued, coalesced, coalescedTotal, rows, dropped, flushes, span }

grid.updates.log({ since: Date.now() - 60000 });   // what arrived, in order

Pausing is a button, not a guess. Inferring it from whether the user looks busy sounds friendlier and is wrong in both directions: too broad and a mouse resting on the grid freezes the feed until someone reloads, too narrow and rows jump the instant somebody stops moving in order to read. An explicit control has no heuristic to get wrong and no invisible state to explain.

Merging continues while paused, so a long pause costs one entry per changed row rather than one per update. Forty updates to one row are one row of work when play is pressed, and coalesced is the thirty-nine, which is the number nobody could see before.

Bounding the rows themselves is separate. The log bounds change history; a streaming source also needs to bound row retention, or a grid left up overnight holds every row it was ever sent. Set source.maxRows and the stream becomes a sliding window, dropping the oldest as new ones arrive and reporting how many it let go through evicted on the progress report.

A time window, not just a row count

maxRows is a count window. maxAge is a time window. They are different promises, and a live feed usually wants the second one.

Keep the last five minutes, and show the last five minutes

const grid = createGrid(el, {
  columns,
  source: {
    mode: 'stream',
    open,
    maxAge: 5 * 60 * 1000,   // keep five minutes of rows
    ageBy: 'ts',             // ...aged by this column; omit for arrival time
    maxRows: 20000,          // ...and never more than this many, whichever bites first
  },
});

createChart({
  grid, container, type: 'line', x: 'ts', y: { col: 'value', fn: 'avg' },
  // The x domain is the last five minutes ending *now*, so the chart
  // keeps scrolling left even while the feed is silent.
  axis: { x: { window: { kind: 'time', span: 5 * 60 * 1000 } } },
});

A count window drifts, and it freezes. maxRows equals “the last five minutes” only while the feed rate is steady: a burst silently shrinks the window to two minutes, a quiet spell stretches it to twenty, and the x axis changes span under the reader. Worse, when the feed goes quiet nothing is evicted and the chart stops moving, even though time is still passing — and the silence is usually the thing worth seeing. maxAge is a span of wall clock, so it means the same thing whatever the feed is doing.

An empty reduction is a gap, not a zero. fn: 'avg' above — and sum, mean, min, max, first and last beside it — read as null when a bucket carries no rows to reduce (BACKLOG-0001088), so a producer that has stopped sending is drawn as a break in the line rather than a value dropping to zero, which would read as a real observation nobody made. count and countValues are the deliberate exception: they are already honest at zero, a tally of rows or of values actually present, so reach for countValues when the reading you actually want is “how many arrived” and zero has to be drawn as zero rather than as a gap.

Two bounds, one eviction path. maxAge and maxRows are independent and compose: both are applied on the same pass and whichever bites first is simply the one that drops rows. Neither is silently ignored. Age eviction reuses the count bound’s machinery outright, so evicted on the progress report and the stream:evicted event carry age evictions exactly as they always carried count evictions — an existing “dropped off the back of the window” readout keeps working with nothing changed.

The span is a bound, not a guillotine. A row lives a little past the span before it goes, and two things add to that. First the eviction slack, ten per cent of the span — exactly the overshoot maxRows already allows on its count — so the row permutation is rebuilt once per block rather than once per arriving row. Second, when the feed is idle, up to one tick of the eviction timer, which runs at a quarter of the span clamped to between 50 ms and one second. The real ceiling is therefore about span × 1.1 + tick, and because the tick has a floor it is proportionally larger the shorter the window: about 10% over at a five-minute window, around 1.25× at ten seconds, and as much as ~1.35× at three. That is the deliberate price of an idle grid that costs no CPU, and it is why the number to reach for is the window you want the reader to see rather than a hard retention limit. The chart’s domain is exact either way — it ends at now — so the extra rows sit off the left edge rather than being drawn.

Which clock: ageBy, or arrival. Given ageBy — a column id, a dotted path, or a function of the row returning a Date, epoch milliseconds or an ISO string — the window follows the data’s own clock, so it means what the producer means. That also inherits the producer’s clock skew: if their clock runs five minutes fast, their rows live five minutes longer than yours. Omit ageBy and rows age from arrival time, when the row reached the source. Arrival time needs no timestamp column and cannot be skewed, but it is not event time — a row delayed in transit is treated as young. A synthetic or metric feed usually wants arrival; a log or event feed usually wants ageBy. A row whose time value cannot be read is never aged out: dropping data because a timestamp was malformed is the worse failure.

Out of order is handled, not reordered. With ageBy the row’s clock need not be monotonic in arrival order, so eviction scans the window rather than walking the head; a late row that is already older than the span is dropped on the same pass it arrived on and counted as evicted, rather than being painted and then withdrawn a moment later. Nothing is re-sorted: a row’s position is still arrival order, only its retention is decided by its time.

The chart axis rolls independently. The axis takes the same WindowSpec vocabulary rolling statistics use — window: { kind: 'time', span } — and its domain ends at now rather than at the newest point, which is what makes the chart keep scrolling with zero new rows. It works with or without maxAge on the source; set both to the same span and the retained data and the drawn domain agree. Only kind: 'time' applies to an axis: a count window over a chart is the source’s maxRows, and kind: 'count' is refused with a warning rather than quietly given a second meaning. The x column has to be continuous and carry wall-clock times — a banded or categorical axis has no domain to roll.

A producer whose clock runs ahead. A reading stamped slightly ahead of the viewer’s clock carries the end of the domain forward with it, so the newest mark is drawn. How far is bounded: a quarter of the span (15 s on a 60 s window). A reading further ahead than that is treated as a producer whose clock is wrong — it does not move the window, it is not drawn, and the chart warns once, naming the column, how many readings were left out and how far ahead they were. Without the bound, one device two minutes fast moved a one-minute window past every other device’s recent readings and the chart drew a single dot (BACKLOG-0001123). If every reading in the window is that far ahead the chart shows its empty state, with the same warning. chart.data().windowed counts the readings dropped at either edge of the window. The fix for the warning is the producer’s clock, not a wider window.

The other edge: a producer that has simply stopped. The case above is a clock running fast; the opposite is a feed that has gone quiet for longer than the window (BACKLOG-0001088) — every reading is older than the span, so every mark would fall to the left of the domain, off the plot, while the axes and legend keep drawing as if the chart were healthy. Rather than draw that, the chart shows its empty state and warns once per chart instance, naming the span and how old the newest reading actually is, so a dead feed reads as “no data” rather than as a chart that quietly stopped moving. Two charts bound to the same stale column each get their own warning — the key is scoped to the chart, not just the column, so a dashboard of tiled charts sharing one timestamp column does not lose the second warning to the first.

Idle costs nothing. Both halves advance on a plain interval — a quarter of the window, clamped to between 50 ms and one second — and never on an animation frame. The source’s wake returns after a single number comparison unless a row is actually due, and the chart’s wake does nothing at all when the document is hidden or the chart is detached. Both timers are cleared on destroy. Measured over a five-minute window holding 50,000 rows with no feed at all, the window’s CPU cost is inside the run-to-run noise of the same source with no bound set: under 0.04% of one core (bench/idle-window.mjs).

The log keeps the raw sequence, not the merged one. Merging is right for applying a backlog quickly and wrong for looking at what happened, because the intermediate states are exactly what a time scrubber would move between. It survives the flush, pending is what is waiting, the log is what happened, and it is capped, so a grid paused over lunch holds the recent past and reports how much it dropped rather than taking the tab with it.

Data Router: a live WebSocket feed

createDataRouter is built for a continuous live feed — ordering, de-duplication, batching, resume after a drop, and moving a row between routes rather than duplicating it are all shipped. What it does not do is open the connection. The host owns the connection; the router owns everything after the message arrives. That is a boundary worth stating plainly, because nothing in the router's own name says so, and the two questions every integrator asks first — “does it handle the socket?” and “how do I send it data?” — both turn on it.

Wire a socket's messages to the router's two entry points

const router = createDataRouter({ rowKey: 'id', seq: 'v' });
router.attach(grid, () => true);

const socket = new WebSocket('wss://example.com/feed');
socket.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.kind === 'snapshot') router.load(msg.rows);        // full keyed diff
  else if (msg.kind === 'delta') router.apply(msg.changes);  // in-place add/update/remove
};

Two message kinds, two entry points. A snapshot (the whole current world, sent once on connect or on resume) goes to router.load(rows) — a plain array of records; it re-partitions everything and applies a keyed diff per route, so rows that did not change do not repaint. A delta (an incremental change) goes to router.apply(deltas) — an array of { op: 'upsert'|'delete', row, seq? } — which adds, updates or removes in place by rowKey, moving a row between routes if its partition changed rather than duplicating it. Getting this backwards is the mistake this guide exists to prevent: load() on every message re-partitions the whole world on every tick (correct only for a snapshot); apply() on the opening snapshot never seeds the store, so every route starts empty. There is no third method for “a WebSocket message” — the host reads kind (or whatever field its own wire format uses) and picks one of these two.

No transport lock-in, and that is a benefit, not a gap. The router takes rows, never a URL and never a socket object, so a real WebSocket, an EventSource, a change-data-capture feed, a long-poll loop or an existing message-bus client all wire up the same way — whatever arrives, hand the router the snapshot array or the delta array. Nothing in packages/modules/data-router/ constructs a socket, so nothing there needs to change when the transport does.

Ordering, dedupe, and batching a fast feed

A socket is not a clean pipe: messages can arrive reordered, a reconnect can replay something already applied, and a fast feed can out-pace how often a grid should repaint. None of that is handled unless it is configured.

Without seq: a reordered packet wins silently — run and see it happen

const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const rows = new Map();
const sink = { rows: { apply({ add = [], update = [] }) { for (const r of [...add, ...update]) rows.set(r.id, r); } } };
const router = createDataRouter({ rowKey: 'id' }); // no seq configured
router.attach(sink, () => true);
router.load([]);
router.apply([{ op: 'upsert', row: { id: 'x', price: 101 } }]); // the newer event
router.apply([{ op: 'upsert', row: { id: 'x', price: 100 } }]); // arrives late, but wins
return rows.get('x').price; // 100 — the stale packet clobbered the fresh one

With seq (dedupe defaults on): the identical reorder, corrected

const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const rows = new Map();
const sink = { rows: { apply({ add = [], update = [] }) { for (const r of [...add, ...update]) rows.set(r.id, r); } } };
const router = createDataRouter({ rowKey: 'id', seq: 'v' });
router.attach(sink, () => true);
router.load([]);
router.apply([{ op: 'upsert', row: { id: 'x', price: 101, v: 2 } }]);
router.apply([{ op: 'upsert', row: { id: 'x', price: 100, v: 1 } }]); // v:1 ≤ last seen v:2 — dropped
return `${rows.get('x').price} dropped=${router.dropped}`; // 101 dropped=1 — corrected, and counted

Without a seq, two rules to know. Within one apply() call, last-writer-wins per rowKey — the last element of the array for a given key is what lands, regardless of which one is “newer” in real time. Across separate calls (separate socket messages), the router has no way to tell a late, stale packet from a fresh one, so it applies whatever arrives, whenever it arrives — an out-of-order delta lands out of order. Configuring seq (a field name or fn(row)) fixes both: within a batch the router sorts by seq before applying, and across calls it keeps a running seqSeen per record and drops (into router.dropped) anything not newer than what it already applied — which is exactly the reconnect/resume gate below, working the same way for ordinary live reordering.

A fast feed: push plus batch/coalesce. Call router.push(delta) instead of apply() and, with coalesce: true or a batch: { intervalMs } configured, deltas buffer instead of applying immediately; rapid updates to the same key settle to a single apply on the timer (or on an explicit flushStream(), useful for a deterministic point such as an animation frame). With no batching mode configured, push applies at once, so it is always safe to feed a socket through push rather than choosing between it and apply up front.

Reconnect and resume

Capture a cursor before the drop; replay after

// While live:
socket.onclose = () => {
  const resumeFrom = router.lastSeq();      // ask the server to resume from here
  const mark = router.checkpoint();         // or persist this per-record map instead
  reconnect(resumeFrom);
};

// On the new connection, the server sends a fresh snapshot plus a replay
// that may include deltas already applied — the same onmessage handles it:
socket = new WebSocket(`wss://example.com/feed?since=${resumeFrom}`);
socket.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.kind === 'snapshot') router.load(msg.rows);
  else if (msg.kind === 'delta') router.apply(msg.changes); // replays are dropped, not reapplied
};

The pattern is snapshot-plus-replay, and the dedupe gate does the rest. On reconnect, load a fresh snapshot (a keyed diff, so unchanged rows do not repaint) and let the feed replay from around the last known point; any delta the router already applied — because its seq is not newer than what checkpoint() holds for that record — is dropped, so only genuinely new deltas advance the state. seenThrough(mark) primes that same checkpoint from a persisted map (e.g. after a page reload), so an early replay is dropped even before the router has applied anything itself in this session. None of this requires the socket to be gone — the router has no idea whether it is talking to the first connection or the fifth.

The knobs a live feed makes you reach for

Four factory options and one route option matter once the feed is real rather than a fixture, and none of them is needed to get started. onUnrouted is the sink for whatever matched no route — it receives the row on load and query, and the whole delta on apply — and is the honest alternative to an attachDefault grid when a stray record is a bug to log rather than a row to show. selectionDebounce (ms, default 16) is how long a linked grid waits before refiltering on a selection change; set it to 0 in a test so every selection.set refilters before the next statement. metricsInterval (ms, default 1000) is the cadence of the on('metrics') emit, which runs only while a listener is registered; a devtools panel inherits it. On a route, backpressure ({ maxHz, minInterval, sample, maxLag }) throttles how often that one viewer repaints under load without touching what it holds — a chart that cannot draw 200 times a second gets { maxHz: 10 }, and the grid beside it stays live.

Persistence survives a reload: choose the store once

router.persist({ key: 'orders', dbName: 'lattice', storeName: 'router' });
await router.restore();   // true when a snapshot was found and applied

persist writes the router's state to IndexedDB under dbName / storeName (defaults are provided; pass your own indexedDB to test against a fake, or a storage pair of get/set to use something else entirely). persisting reads false when the store was unavailable and the router degraded to memory, which is the case to check before promising a user their view will be there tomorrow.

Several independent feeds, one router

Two sockets, two sources, one merged store

const orders = router.addSource('orders');
const inventory = router.addSource('inventory');

const ordersSocket = new WebSocket('wss://example.com/orders');
ordersSocket.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.kind === 'snapshot') orders.load(msg.rows);
  else if (msg.kind === 'delta') orders.apply(msg.changes);
};
// inventorySocket wired the same way, against the `inventory` handle

addSource(id) returns a per-feed handle — its own load/apply/push/remove — so each socket is wired to its own handle exactly as a single feed is wired to the router directly; the router merges every source into one keyed store, namespaced by key when two feeds' ids could otherwise collide. There is no separate lookup method to re-fetch a handle later — keep the reference addSource returns, the same way the code above keeps orders and inventory.

No backend yet: MockWebSocket frames messages identically

import { MockWebSocket, opsFeed } from 'lattice-grid/modules/mock-socket';
const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }) });
// everything above this line is the only thing that changes going live:
// const socket = new WebSocket('wss://example.com/feed');

Every example on this page is executed, against the real module and MockWebSocket, in demo/router-websocket.mjs (node demo/router-websocket.mjs) — run it to see each property above as output rather than prose.

Diagnostics and devtools

Data grids fail in ways that are hard to diagnose from outside. This is the grid saying what it is actually doing.

Asserting on DOM writes

const before = grid.diagnostics.renders().dom.cellWrites;
await doTheThing();
expect(grid.diagnostics.renders().dom.cellWrites - before).toBeLessThan(200);

The API came first and the panel second, on purpose. Instrumentation written behind a UI gets shaped by the layout: it reports what is convenient to display rather than what is true, and it cannot be tested. An API that stands on its own can be asserted against, and the assertion above is not one most grids can support.

Most of it already existed and was unreachable. The renderer had counted cell writes, row updates and paints all along; the column store could already report a real byte footprint per column, summing backing array, presence bitset and dictionary. Neither was reachable from the public API. Exposing what a system already knows is usually a better first move than measuring something new.

Warnings are mostly collection, not detection. The grid has 160 places that warn once per cause, each already carrying a stable de-duplication key, which is exactly the stable identifier a support conversation needs. They went to the console and nowhere else. The console interleaves with your own logging, does not survive a reload, and cannot be asked what it has already complained about. They are now kept as records too.

Every warning names values, not just a condition. "Something is slow" is a warning nobody can act on. Each carries the specific numbers, a stable id, and is dismissible for the session but not permanently, a permanently dismissible warning is one nobody sees again after the person who dismissed it leaves.

The checks are tested for silence as much as for detection. A clean grid must raise nothing. A checker that cries wolf is one developers learn to ignore, and then it is worth less than no checker at all. The accessibility checks found two false positives in themselves during development: first comparing aria-rowcount against the row count when ARIA counts header rows too, then counting header row *elements*, of which a single-level header has three because the header is built once per pinned region.

Instrumentation must not change what it measures. Counters are integers incremented where the work already happened. Render phases are four performance.now() marks around existing sections. Timings are sampled, a bounded window of recent operations, and every report names which of its figures are sampled, because a number whose provenance is unclear is worse than no number. Paint wait is the browser's and is deliberately not claimed.

Render causes are captured, not inferred. By the time a paint runs, several distinct causes have collapsed into the same dirty flags, so working backwards gives a plausible answer rather than a true one. The renderer records the structural reason when the invalidation arrives; the semantic one (filter, sort, data) is only knowable from the event that preceded it, so the DOM layer supplies that and the structural reason is the fallback.

Providers are wrapped where they are installed, not at each call site, so one added later is instrumented by construction. The wrapper returns exactly what the original returned and re-throws exactly what it threw. Failures are kept, because a provider failure is usually swallowed by the host application's own error handling before a developer sees it, and "the grid is behaving strangely" is where that conversation otherwise starts.

The heat overlay uses two colours because there are two findings. A cell written with a value it did not hold is work the grid had to do. A cell rewritten with the value already in it is work it did not. Only the second is waste, and a counter alone will never tell you where it is. The overlay records a baseline when switched on, because otherwise the first paint has nothing to compare against and tints nothing, which reads as broken rather than as empty.

The module imports nothing. Not the grid, not a shared helper. The bundler inlines whatever a module imports: the framework adapters stay small because createGrid is handed to them, while the web component carries the grid with it because it imports it. A devtools module that imported anything from core would put the whole grid inside a bundle whose entire promise is that deployments not using it pay nothing.

The support bundle carries no row data. Configuration, query state, timing, warnings, provider statistics, version and environment, and nothing from your data. Stated as a guarantee because a bundle that had to be inspected for confidential values before sending is a bundle that never gets attached to the ticket.

It observes and never mutates. A configuration editor in a debug panel is tempting and would create a second path into grid state that has to be kept correct forever. No telemetry: nothing leaves the browser unless you export a bundle yourself.

Collaborative presence

Who else is looking at this grid, and what they are doing.

An in-memory provider, which is all the interface requires

const handlers = new Map();
const providerFor = (id) => ({
  subscribe(fn) { handlers.set(id, fn); return () => handlers.delete(id); },
  publish(state) {
    for (const [peer, fn] of handlers) if (peer !== id) fn(state);
  },
});

Presence carries intent, never values. This is the line that matters most. A peer's committed edit reaches the grid as data, through the channel you already use for data. Presence is throttled, lossy and ephemeral by design, so a value carried on it is a value that can be dropped, and that is the class of bug that appears once a month in production and cannot be reproduced on demand.

Positions are row keys, resolved against your view at render time. Peers sort and filter independently, so index 12 is a different record on every screen. Publishing an index would put a colleague's cursor on an unrelated row the moment either of you sorted. The cost of this is real: the grid resolves a key to a position rather than reading one, and it is the difference between the feature working and the feature lying.

Idle is measured from when a message arrived, not from what it says. Clocks between clients disagree by seconds routinely and by minutes occasionally. Keying idle detection on the sender's timestamp means a peer with a fast clock never goes idle and one with a slow clock is idle immediately. The sender's timestamp is kept for inspection and decides nothing.

Publishing throttles rather than debounces. A debounce sends nothing until the user stops moving, so every peer sees a cursor that teleports on pause instead of moving. The leading edge goes out immediately and the trailing edge carries wherever it settled.

Publishing stops while the tab is hidden. Nobody is moving that cursor. Receiving continues, so coming back to the tab shows the current state rather than an empty roster filling in slowly.

A peer's cursor is dashed; your focus ring is solid. The distinction has to be in the kind of line, not only its colour. Colour alone fails for anyone who cannot separate two hues and fails for everyone at a glance, and the palette originally contained the exact value of --lattice-focus-color, so the first peer assigned drew a cursor identical to the local user's own selection. An active edit is solid and tinted, because an edit is not a cursor and those two must not be confused with each other either.

Nothing is inserted into the grid. Every treatment is an attribute and a custom property written onto a cell that already exists, drawn with an outline and a pseudo-element. That is what keeps presence from shifting layout, covering an in-cell chart or swallowing a click: none of which survives an implementation that appends overlay elements. The overlay layer is pointer-transparent and presence deliberately does not opt back in; only the roster does, because it is a control.

The roster is the part people use. More than the cursors, in practice. It carries the name as well as the colour, because colour alone is not a signal everyone can read, and it reports peers whose rows are not in your view rather than omitting them, an absent peer reads as a disconnection that has not happened.

A parked cursor does not fade. The label does, after a couple of seconds, because permanent labels over a dense grid are unreadable. The border stays, dims at idle, and goes only on removal: a cursor that vanished while its owner was still connected would report exactly the thing this feature exists to prevent.

Locking is advisory, and the documentation says so because a developer who believes otherwise will skip the conditional write. Presence is throttled and can arrive out of order; two clients can enter an edit in the same instant. What actually resolves the conflict is the conditional write in edit.commit, which returns a conflict and rolls the optimistic edit back. Locking narrows the window. It does not close it. A stale claim is disregarded after a much shorter window than peer removal, because a lock held by someone who shut their laptop blocks a cell nobody is editing.

Not in this release: transport, reconnection or authentication; operational transform or CRDT merging; presence history; text-level cursors inside a cell editor; follow mode; chat.

Cell comments

Threaded discussion attached to individual cells, for reviewing data with other people without leaving the grid.

Wiring a provider

comments: {
  provider: {
    loadIndex:  (rowIds, fields) => api.counts(rowIds, fields),
    loadThread: (cellKey)        => api.thread(cellKey),
    addComment: (cellKey, body, parentId, ctx) => api.add(cellKey, body, parentId, ctx.value),
    editComment: api.edit, deleteComment: api.remove,
    resolveThread: api.resolve, unresolveThread: api.unresolve
  },
  rowLabel: (row) => row.data.name
}

Comments are not row data, and never enter the column store. They have a different lifecycle: append-mostly, sparse against the row count, carrying their own identity and timestamps, subject to their own permissions, and outliving the values they annotate. Putting them in the store would mean columnarising a field that is empty for almost every row and rebuilding it on every write.

Two levels of data, and only one of them is cheap. The index maps a cell to { count, unresolved, updated } and nothing else; it is consulted on every repaint, so it must never carry bodies. A thread is the bodies for one cell, loaded when the thread opens and dropped when it closes. Holding every thread a user opened would accumulate the whole comment dataset over a long session to render a few triangles.

Stable row identity is a hard requirement, enforced rather than documented. A comment is keyed on row identity plus field. Row index changes under sort, filter and grouping, so a comment keyed on it reattaches to whichever row later occupies that position, and a comment on the wrong row is worse than no comment at all. A grid with no rowKey disables comments and names them in the same single warning as the other identity-dependent features.

Identity has to survive more than the session. Comments outlive the page that wrote them, so a key that is stable only within one load (anything derived from arrival order, for instance) is not enough. Reload the data in a different order and every thread points somewhere else. If you have only used the grid client-side you may never have needed a durable identity before; you do now.

Writes are optimistic, and rejections are taken back. The author sees their own comment at once, faded until the provider confirms. A rejection removes it rather than leaving the grid displaying something the server refused. The same pattern as cell editing, for the same reason.

The grid authorises nothing. A comment may carry can: { edit, delete, resolve } and the affordances follow it, but that is a convenience for the person looking at the screen. It is not a control, and the documentation says so here rather than leaving it to be assumed: hiding a button stops nobody who opens the console. Reject in the provider.

A body is user input that has round-tripped through your storage. That is the exact shape of a stored cross-site script, so the default path sets text and nothing else. Turning on markdown buys emphasis, code and links: three constructs, built as elements rather than parsed as markup, with any scheme other than http, https and mailto refused. A refused link still shows its label, so nothing the author wrote vanishes without trace.

The marker cannot move the cell's contents. It is a corner triangle drawn with a border on a pseudo-element, so it occupies no space in the layout: no shifted text, no rewrapped number, no displaced sparkline. That constraint is why it is a corner rather than a badge: every other position in a cell is already spoken for. Only the corner opens a thread; a click elsewhere belongs to selection, and taking it would make commented cells behave unlike every other cell. The hit region is larger than the drawn mark, because at compact density the triangle is about seven pixels across.

A comment shows the value it was written against whenever that no longer matches the cell. Without it, a note reading "this looks too high" sits beside a number it never described and the reader concludes the comment is wrong. Changing a value never deletes or invalidates a comment.

Filtered-out comments are hidden, not lost, and the grid says so. hiddenUnresolved() reports what is still outstanding on rows the filter is hiding, because a user who filters and sees no markers should not conclude there is nothing left to deal with. The status bar carries this: its comments panel reads “3 unresolved comments on hidden rows” and is silent whenever the count is zero, so a grid with nothing outstanding gains no permanent furniture. The panel is in the default set, and can be placed explicitly like any other:

statusBar: { panels: ['rowCount', 'comments'] }

The count returns zero rather than a number it cannot stand behind: it is only meaningful once the index covers every row, so it stays at zero, and the panel stays silent, until loadAll() has resolved.

The comments-only filter is refused rather than approximated. Restricting the grid to rows carrying comments needs the index to cover the whole row set, not just what has been scrolled past, a partial answer would hide precisely the rows the user opened it to find. Call loadAll() first; until complete is true, filterToCommented() returns false and does nothing.

Comments stay available while streaming, unlike header histograms: a comment does not move when new rows arrive. Index loads for new rows follow the same debounced viewport path. A thread whose row is evicted by a bounded window closes with a short explanation rather than hovering over a row that has gone.

Keyboard and screen reader. Alt+M opens the thread on the focused cell: Alt because the grid binds nearly every unmodified key to navigation and editing. The panel traps Tab, which it has to: the grid behind it is still there and still focusable, so without the trap a keyboard user would be moving through cells with a dialog open over them. Focus returns to the originating cell on close rather than being dropped at the top of the document. Cells announce their comment count and unresolved count through aria-description rather than their label, because the label is the cell's value and burying a count inside it would make every commented cell read as something other than what it holds.

Not in this release: mentions, notifications, rich text, attachments, reactions, row-level and column-level comments, and export of comments. The grid opens no transport of its own: if your application pushes updates, call refresh() and the index reloads.

Header histograms and facet filtering

The shape of a column, drawn in its heading, and clickable. Explore a dataset by clicking through headers instead of writing queries.

Turning them on

facets: { enabled: true }

// per column, layered over the grid's settings
{ field: 'price', type: 'number', facet: { strategy: 'quantile' } }
{ field: 'notes', facet: false }

The column being filtered is not counted against its own filter. Every other active filter applies; that column's own conditions are pruned out of the tree before counting. This is the whole of faceted browsing and it is the part that is easy to get subtly wrong, a self-filtered chart collapses to a single bar the moment you click one, and there is then no way to see what you excluded or to widen the selection. Getting it wrong does not degrade the feature, it removes it.

Pruning is not symmetric across operators. An and group narrows with each condition, so dropping one widens the result, the direction faceting wants. An or group widens with each branch, so dropping one would show fewer rows than the user's actual filter. There is no partial answer that is correct, so a disjunction naming the column is dropped whole.

Bucket edges are placed once and kept. They are computed against the unfiltered column and survive every filter change until the data is replaced. Not only an optimisation: bars that resized on every click would make the chart unusable as a control, because the thing you are pointing at would move as you pointed at it.

Each bar carries two readings. Its full height is the bucket's share of the unfiltered column; the solid fill inside is how much survives the current filters. Either alone misleads: scaling to the filtered maximum draws a full-height chart out of three surviving rows, and scaling everything down together flattens the whole chart into a few pixels the moment anyone filters anything.

The filters are ordinary filters. They go through the same filters.set as everything else, so they undo, serialise into saved views, and appear in whatever filter UI already exists. Nothing downstream can tell a filter made by clicking a bar from one typed into the filter panel. A drag emits a between range rather than a set of bucket indices, so it still means the same thing after the data is replaced and the edges move.

Selection is derived, never stored. Which buckets look selected is read back out of the filter tree. Remove the filter through the filter panel, an undo or a saved view and the chart is correct without anything having to tell it.

High-cardinality columns are refused, and it costs nothing to know. Text columns are dictionary-encoded in the store, so the distinct count is a property read rather than a scan. The first column anyone points this at is a name or an id, and one hairline per customer looks like a rendering fault rather than a distribution. Above cardinalityLimit the chart is suppressed, or shows a top-N with an aggregated remainder if you ask for aboveLimit: 'topN': aggregated rather than truncated, because silently dropping the tail would misrepresent the bars it did draw.

Nulls are never dropped. They land in a terminal bucket, always last, and the counts always sum to the row count. A column where nine thousand of ten thousand rows are empty is a fact about the data, and a chart that quietly showed the thousand would be lying about the shape. NaN joins them rather than forming its own bucket: it is the same answer to the same question.

Live streams suppress the charts. Constantly shifting distributions are unreadable, recounting on every batch is wasteful, and a filter control whose buckets move under the pointer is actively hostile. Filters already made stay applied, because they are ordinary filters. Pausing the stream brings the charts back, a paused stream is a still one: unless you set whilePaused: false.

Counting runs off the main thread above workerThreshold. A distribution was the first work the grid moved to a Worker: nothing waits on a histogram, which is what made it offloadable without an async pipeline. A portable sort — a built-in collation with no custom comparator — now also recomputes off-thread above the threshold, serving the prior order until the new one lands. Filtering and grouping still run on the main thread. The column is copied, or shared where cross-origin isolation makes SharedArrayBuffer available; it is never transferred, because transferring would detach the buffer the grid is still rendering from.

The Worker settings. useWorker and workerThreshold decide whether and when a distribution is offloaded. Two more control how the Worker is built, and both are settled when it is constructed: changing either discards the running Worker so the next offload builds a new one.

SettingWhat it does
workerUrlLoads the Worker from a URL you host instead of a blob:. Required under a Content-Security-Policy that forbids blob: workers: without it the Worker cannot be constructed at all on such a page, and compute stays on the main thread.
sharedMemoryOff by default. Passes columns to the Worker in a SharedArrayBuffer rather than copying them on every message, at the cost of retaining a shared copy of each column that crosses. Needs the page to be cross-origin isolated; where it is not, it falls back to copying and says so once.

grid.diagnostics.renders().worker reports what the Worker host is actually doing: whether one was spawned, how many calls ran locally versus remotely, and the threshold, sharedMemory and workerUrl it was built with.

Server-side sources need a provider, and its absence is silent. A grid holding one page of data cannot compute a distribution over the whole set. Supply a function and it receives the column, the pruned filter state and the bucketing settings, and returns counts. Without one the charts are simply absent, no error, because most deployments will never supply one. Be clear-eyed about the load: results are cached against the filter state, but this is one query per column per filter change, and a grid with eight faceted columns asks eight questions every time a filter moves.

Keyboard and screen reader. Focus enters the chart from the header and arrows move between buckets, so a column costs one tab stop rather than twenty. Enter toggles, Shift with arrows extends a range on ordered columns, Escape clears. Selected buckets carry an outline as well as a colour. Beyond per-bucket labels the chart carries a sentence describing the distribution's shape, because twenty bucket readings do not add up to "most of the mass is at the low end", and that shape is the entire value of the chart.

Time scrubber

Move the grid back through recent data changes: what did this look like a minute ago, before that number moved.

Scrubbing

grid.timeline.attach();      // start recording; the window fills from here

grid.timeline.seek(5);       // stand five changes back
grid.timeline.step(-1);      // one further back
grid.timeline.at();          // the moment being shown
grid.timeline.toLive();      // return, applying everything stepped over

grid.timeline.detach();

Attaching puts a control on the grid. A slider along the bottom with two readings beside it: how long ago, and the clock time. Relative answers the question actually being asked; absolute is what someone reads out to the person next to them. It moves the grid while the handle is dragged rather than on release, and it turns accent-coloured the moment you are off live, because a grid quietly showing stale data is the failure this control can cause. It removes itself on detach().

It reads the data, not your actions. Undo history records what the user did (sorts, filters, edits) which is rarely the question. This reads the change log: a bounded, timestamped, deliberately unmerged record of everything that arrived, so the intermediate states are all still there to move between.

Nothing is scrubbable before attach(). What a value used to be is not recoverable after the fact (no other part of the grid remembers it) so recording has to be switched on before there is a past to move through. It is off by default because reading a row per key on every change is real cost on a hot feed, and paying it for a scrubber nobody opened would be the wrong default.

Scrubbed back, the grid is not live. Changes keep being recorded but are not applied, because applying them would fight the position being held. Returning to the present applies everything missed.

Value changes reverse; row additions and removals do not. An add would need a removal and a remove would need re-insertion at its old position, and neither is recoverable from what the log holds. A window containing them scrubs over the value changes and leaves the row set alone: stated plainly because the alternative is a scrubber that silently half-works.

What moved is marked. Seeking compares each affected row before and after and marks the cells whose value changed, in --lattice-timeline-changed. Without it a scrub is nearly unreadable: the grid moves, and on a row twelve columns wide the one number you are hunting for goes past unseen.

The mark is held, not flashed. It stays until the next seek clears it. Every other transient signal in the grid fades on a timer, and this one deliberately does not, a scrub is someone hunting for what changed, and a highlight they can miss while reading the other end of the row helps nobody. timeline:seeking fires before any change is applied, so a five-step drag clears once and marks once rather than strobing per entry.

It compares column values, not raw fields. A computed column has no field of its own; diffing the source row would leave it silently unmarked while its number visibly moved. Reading through the column instead costs a little more and marks what the viewer can actually see change, which is the only definition of "changed" that matters here.

The window is bounded by rows, not only by changes. A cap on entries alone does not bound memory, because an entry is not a fixed size: one carrying a single changed cell and one carrying a fifty-thousand-row batch both count as one. So the log holds at most updates.logLimit changes (2000) and updates.logRows rows between them (100,000), dropping oldest-first on whichever it meets. It matters more here than it looks: the log is what keeps superseded row objects alive after the source has swapped in their replacements, so on a feed delivering five thousand rows a batch an entry-only cap retains twenty million of them. The one exception is a single change larger than the whole cap, which is kept: emptying the log would be worse than being briefly over, and it would drop the newest change rather than the oldest. Watch held against heldLimit in grid.updates.stats(); rows is a lifetime total and says nothing about memory.

Charts scrub like anything else. A sparkline column redraws to the series the row held at that point, and its cell is marked with the rest. The exception is the delta renderer: it samples on a wall-clock timer and compares against its own previous sample, so it reads a seek as a genuine movement and draws an arrow for it. Keep it off a grid you intend to scrub.

Presentation mode

Renders the grid for a room: full-screen, application chrome hidden, everything enlarged. The data stays live and queryable throughout, so a question from the audience is answered by filtering in front of them rather than promised as a follow-up.

Starting and stopping

grid.presentation.start();                  // 1.5x by default
grid.presentation.start({ scale: 2 });
grid.presentation.start({ chrome: ['statusBar'] });   // keep some chrome

grid.presentation.nudge(1);                 // live, or Ctrl/Cmd +
grid.presentation.setScale(1.8);
grid.presentation.stop();                   // or Escape
KeysDoes
EscapeLeave, restoring the grid exactly as it was.
Ctrl/Cmd + = / -Enlarge or reduce live, a laptop on a call and a projector at the back of a room are different problems.
Ctrl/Cmd + 0Back to the default enlargement.

The scale multiplies your density, it does not replace it. A grid built at spacious presented at 1.5x is still recognisably that grid, half as big again, which is what makes a presentation look like the product rather than like a different one. Virtualisation follows the enlargement, so rows are positioned at the size they are drawn.

Full-screen is the maximiser, not a second implementation. A grid the user had already maximised stays maximised when the presentation ends: leaving it would be undoing something the presentation did not do. Chrome hidden on entry is recorded and put back, so an element the host had already hidden is not revealed on exit.

Events are presentation:started, presentation:ended, presentation:scale and presentation:changed: colon-separated like every other grid event rather than the camelCase the original brief used, so a host subscribing to them does not have to remember which family a name belongs to.

Views are the slides

grid.presentation.start({ views: ['escalations', 'at-risk', 'margin-watch'] });
grid.presentation.step(1);          // or an arrow key, space, Page Down
grid.presentation.goTo(0);          // or Home / End
grid.presentation.reset();          // or R: back to the view as saved
KeysDoes
→ ↓ Space PageDownNext view.
← ↑ PageUpPrevious view.
Home / EndFirst or last.
RPut the current view back as saved, discarding anything sorted or filtered since arriving at it.

They are the saved views you already have. A view captures the column set, order, widths, filters, sorts, grouping and density; stepping applies each through the ordinary views.apply, as a single undo entry. Nothing about presenting changes what a view means.

The stepping keys only bind when there is a sequence, and never while something is being typed into. Without a deck those keys belong to the grid, a presenter with no slides still expects Page Down to scroll, and a quick filter answering a question from the room must not advance the deck on the space bar.

Stepping past either end sits there. It does not wrap: a presenter who sees the first slide again thinks the deck has restarted.

Transitions are a cross-fade, not continuous row motion. Rows are pooled and virtualised, so an element holding a row before a view change may hold a different row after it: only rows visible in both states could be animated between positions, and half a movement draws the eye to whichever rows happened to survive rather than to the change itself. prefers-reduced-motion removes it; a projected fade is far larger than one on a laptop, so someone who asked for less motion meant it.

Spotlight, redaction and unattended cycling

// light one row across two columns; everything else recedes
grid.presentation.setSpotlight({ keys: ['R42'], colIds: ['margin', 'utilisation'] });
grid.presentation.setSpotlight(null);        // after the point is made

// a wall display cycling saved views with nobody at the keyboard
grid.presentation.start({ views: [...], autoAdvance: 15000 });

// keep some chrome
grid.presentation.start({ chrome: ['statusBar'] });

Spotlight dims what it is not on, rather than lighting what it is. Rows and columns combine as an intersection, so naming both lights the cells where they meet. The dimming stops at 0.28 rather than going further: the audience has to see that there is more data and roughly what shape it is, or the spotlight reads as a filter and the room believes the other rows are gone. It is opacity alone, so a dimmed sparkline keeps its colours instead of flattening to grey.

A spotlight does not survive a view change. It belongs to the point being made, not to the deck: carried forward, it leaves the audience looking at a lit row that no longer means anything.

Redaction travels in views and undo. It is part of grid state, so a saved view carries its own masking and a view that redacts salary redacts it every time it is shown. Toggling is a tracked action, so it undoes like any other change.

Auto-advance wraps, unlike a keypress. An unattended display that stopped on the last view would show one screen for the rest of the day.

Escape ends the presentation, not just full screen. A presentation runs full screen with its chrome hidden, so leaving full screen without ending it would drop a chrome-less enlarged grid back into the page with no control left to turn it off. The maximiser stays the only listener on the key and the presentation follows it, which keeps one Escape doing one thing: an open editor or menu still closes first. A grid built with maximise: false binds the key directly instead.

Capturing a still

const blob = await grid.capture({ scale: 2 });
await grid.capture({ scale: 3, fileName: 'q3-margins.png' });   // and save it

grid.on('presentation:captured', (e) => {
  console.log(e.width, e.height, e.bytes, e.mimeType);   // 1800 600 41030 'image/png'
});

Mounting the bar elsewhere needs the grid's class. Every rule that styles the prompt bar is scoped under .lattice, and every colour token is declared there, so a bar mounted into your own chrome through ai.element arrives unstyled. Add class="lattice" to the container, and the same data-theme the grid carries, if you have set one, and it picks up the theme.

It photographs the browser's own rendering. The grid is cloned, every computed style is inlined onto the clone, and the result is wrapped in an SVG foreignObject and drawn to a canvas, so the picture is what the browser drew, not a second renderer's guess at it. That matters here more than usual: every decoration, sparkline and pill the cell layer produces comes out right without being reimplemented.

Virtualisation makes it cheap. Only the rows on screen exist in the DOM, so capturing a million-row grid clones the thirty rows a camera could have seen anyway. A full-screen capture at scale: 2 takes around a second.

Cross-origin images are refused before the work starts. They taint the canvas, and a tainted canvas fails at the very last step with a SecurityError that names nothing, so the check runs first and the error names the offending URL. Serve the image same-origin, inline it as a data: URL, or hide the column.

Two further limits, both inherent to the technique: web fonts need embedding to appear (Lattice's default system-ui stack is unaffected), and CSS pseudo-elements are not captured.

Drawing over the grid

grid.annotate.use('pen');                    // pen · arrow · rect · highlight
grid.annotate.use('arrow', { colour: '#e0245e' });
// A durable text label anchored to a cell, seeded or added (BACKLOG-875).
grid.annotate.add({ type: 'text', text: 'Q3 spike', points: [{ x: 232, y: 72 }], background: '#fffbe6' });
grid.annotate.undo();
grid.annotate.clear();
grid.annotate.use(null);                     // hand the grid back

It never touches data. Nothing in the layer reads a row or writes one. A grid with annotations sorts, filters and exports exactly as one without them.

It is inert unless a tool is chosen. The canvas is not even created until the first use(), and carries pointer-events: none whenever no tool is active, so scrolling, selection and editing pass straight through. A presenter who has finished drawing must not discover the grid has stopped responding.

Marks are anchored to the data, not the screen. They are stored in content coordinates and redrawn with the scroll offset subtracted, so a circle drawn round a cell travels with that cell rather than hanging over whatever scrolled underneath it.

They are transient. Marks annotate a moment, so they are cleared when the presentation ends. A capture taken while they are on screen includes them, the canvas bitmap is carried into the still deliberately, because cloneNode copies a canvas element and not one pixel of what was drawn on it.

No tool shortcuts are bound. The keys a presenter would want are already taken by stepping and by the grid itself, and a shortcut that silently shadows Page Down is worse than one the host chooses. Bind your own to use().

Accessibility

The grid is built to WCAG 2.2 level AA. What follows is what it does, what it does not do yet, and the keyboard map in full: stated plainly, because a conformance claim that overstates is worth less than one that admits its edges.

Keyboard

Every operation is reachable without a pointer. Resizing and reordering a column were once drag-only; both now have key bindings and menu items, so nothing depends on dragging.

Entering the grid. The grid is one stop in the page's tab order. Pressing Tab into a grid that has not been used yet puts focus on the grid itself, and the grid draws a focus ring around its own edge at once, in the theme's focus colour, so a keyboard user can see where focus went before pressing anything else. In Windows High Contrast Mode the ring is drawn in the system text colour. A screen reader announces the grid (or tree grid), its row and column counts and whether it is read-only. The first arrow key, Home or Ctrl+Home moves focus to a cell, and the ring moves with it. From then on the cell you were on is the grid's tab stop: Shift+Tab from the first cell leaves the grid, and Tab back into the grid returns to that cell. The grid takes the tab stop back only when that cell is outside the rendered rows, and then draws its own ring again.

KeysDoes
In the data
ArrowUp/Down/Left/RightMove the focused cell
Home or EndFirst / last cell of the row
Ctrl+Home or Ctrl+EndFirst cell of the first row / last cell of the last row
PageUp or PageDownMove one viewport of rows
Tab or Shift+TabNext / previous cell, wrapping across rows
EnterStart editing the focused cell
SpaceToggle selection of the focused row
EscapeCancel the current edit or drag
Alt+ArrowRight or Alt+ArrowLeftExpand / collapse a group or tree row
Ctrl+Alt+HMove focus to the column header
Ctrl+Alt+PMove focus to the tool panel
Shift+F10 or ContextMenuOpen the context menu for the focused cell
Ctrl+FFind in the grid
Alt+Shift+ArrowUp or Alt+Shift+ArrowDownMove the focused row, when row reorder is enabled
On a column heading
ArrowLeft or ArrowRightMove between headings
Ctrl+ArrowLeft or Ctrl+ArrowRightFirst / last heading
Enter or SpaceSort by the column, Shift to add to the sort
Alt+ArrowLeft or Alt+ArrowRightResize the column, Ctrl for a coarse step
Shift+ArrowLeft or Shift+ArrowRightMove the column
Alt+ArrowDownOpen the column menu
ArrowDown or EscapeReturn focus to the data
In the tool panel's column list
GGroup by the column, or stop grouping by it
VAdd the column to values, or take it out
PPivot by the column, or stop pivoting by it
Shift+ArrowUp or Shift+ArrowDownMove the column

This table is generated from the bindings the build ships and checked on every build, so it cannot drift from what the grid actually does.

What a screen reader is told

The grid reports itself as a grid, or a treegrid when it holds a hierarchy, and the role follows the configuration rather than being fixed when the grid is created. Rows and cells carry their position in the dataset, not in the rendered window: a reader on row 500,000 of a virtualised grid is told exactly that, which is the point most grids get wrong. Rows in a hierarchy also carry their position among their siblings, since a reader cannot count siblings that were never rendered.

Focus is real focus, moved onto the cell, rather than aria-activedescendant. It is restored after a row is recycled or scrolled out and back, and the grid holds a single tab stop, so tabbing in and out crosses it once.

State changes are announced: sorting, filtering, selection, grouping, expanding and collapsing, paging, undo and redo, pasting, and rows arriving or leaving on a live feed. A feed is summarised on an interval rather than narrated, because a reader queues what it is given and a fast feed would leave someone listening to counts that are no longer true. A rejected edit is announced with the value that was put back, which is the change users most need to hear about and the one a visual marker alone cannot convey.

Colour and contrast

No information is carried by hue alone. The high-contrast theme runs text at 21:1 and borders at 6.1:1. In Windows High Contrast Mode the grid translates its state into borders and system colours rather than fighting the palette: see theming for what that means in detail.

How this is checked

The grid carries its own accessibility rules, and they run on every build against each configuration that differs structurally: flat, grouped, tree, pinned, editing, paginated and with a tool panel: rather than against one sample grid. The same rules are available live from the devtools panel, where they can also read colour and measure targets.

Be clear about what that proves. These are our own rules covering what a data grid gets wrong, not a general-purpose engine, and automated checking of any kind catches a minority of real problems. They are a regression net (they stop a fix being undone silently) and not evidence of conformance.

Bigger targets for touch

The grid meets the minimum target size on its own. That minimum is a conformance floor, not a comfortable size for a finger: both mobile platforms recommend nearer 44 pixels.

targetSize: 'large'

This raises the hit areas and leaves the type where it is, which is the distinction that matters: a touch user wants a larger target, and a low-vision user wants larger text. Density is the control for the second, and the two combine, a compact grid with large targets is a reasonable thing to want on a tablet.

It applies by itself under a coarse pointer, since the person holding one is both who the criterion is for and the least likely to go looking for a setting. Pass targetSize: 'default' to opt out of that.

Density alone does not do this. It scales the header, the rows and the type, and leaves the affordances inside them exactly as they were: measured at every preset, the menu button stays 24 pixels, the filter 16 and the resize grip 10. A spacious grid has the room going spare and controls no larger than a compact one, which is the gap this fills.

Known limits

Stated because a report that claims everything invites the one question it cannot answer.

Density

One number drives the grid's geometry. A preset sets it; every token derives from it.

Four presets, or a number between them

createGrid(el, { density: 'spacious' });
createGrid(el, { density: 1.4 });   // anything between the presets
grid.set('density', 'compact');     // live
PresetScaleRowFontFor
compact0.8523.8px12.7pxThe default. Dense operational and financial grids; most rows on screen.
standard128px13pxRoomier than the default, and what the grid rendered before density was connected.
comfortable1.542px14pxRoomier application tables.
spacious256px15pxModern app listings with avatars and thumbnails.

Type does not scale with the rows. The row doubles from compact to spacious while the font moves about 18%. Scaling text against row height reads as a children's book long before the rows look generous, so height and spacing follow the scale directly and type is damped against it.

Virtualisation follows the token, not a separate number. Row positions are computed from the resolved --lattice-row-height, so a host that overrides that token by hand gets the virtualisation to agree. An explicit rowHeight in config outranks both, a host that names a number means it.

Height alone will not reproduce a modern app listing. Those designs pair generous rows with two-line cells (a bold title over a grey sub-label) and an avatar or thumbnail. spacious gives the room, an image column gives the picture, and twoline gives the second line.

Image columns

A column whose value is a URL, drawn as a picture. Avatars beside a name, product thumbnails, company logos.

A circular avatar

{ field: 'avatar', type: 'image', layout: { width: 72 },
  cell: { props: { shape: 'circle' } } }
PropTypeDescription
shape'rounded' | 'circle' | 'square'Defaults to rounded. circle is the avatar case.
sizenumberPixels. Omit and the box follows the density scale at 0.68 of the row height.
fit'cover' | 'contain'cover by default, so a mixed set of aspect ratios still forms a tidy column.
altstring | functionAlternative text. Defaults to the cell's formatted text.
loading'lazy' | 'eager'lazy by default.

Only image URLs load. http, https, blob: and data:image/ are permitted; everything else is refused, including a data: URL claiming to be anything other than an image. Relative URLs pass, since they cannot name a scheme. A grid drawing URLs that arrived in a data feed is exactly where a bad one gets through. Note this differs from the link renderer, which refuses data: outright: correct for an anchor, wrong for a picture.

A missing or broken image never moves the column. The box is sized from the row height whether or not the picture loads, and a failed load leaves it empty rather than showing the browser's broken-image glyph, which is a different size in every engine.

Exports carry the URL. CSV, Excel and the clipboard all get the text, not markup. Values are dictionary-encoded, which pays unusually well here: one URL per user or per company repeats down the whole column.

Two-line cells

A bold primary line over a quieter secondary one, taken from a second property of the same row. A name over an email, a title over a category, a company over its sector.

Naming the second line

{ field: 'name', cell: { render: 'twoline', props: { secondary: 'email' } } }

// a dot path reaches a nested field without a callback
{ field: 'name', cell: { render: 'twoline', props: { secondary: 'contact.email' } } }

// a function for anything the row does not already hold
{ field: 'name', cell: { render: 'twoline', props: {
    secondary: (p) => `${p.data.city}, ${p.data.country}`,
} } }

// `format` decorates whatever `secondary` produced
props: { secondary: 'user', format: (v) => v ? `User: ${v}` : '' }

It needs the room. Two lines do not fit in a 28px row. Pair it with density: 'comfortable' or 'spacious', or a rowHeight of 40 or more. Nothing stops you using it in a shorter row; the second line is simply clipped.

The second line reads the row's data, not another column. It is usually a field nobody wants a column of, and requiring one would mean declaring a column purely to hide it. A path that does not resolve leaves the line empty rather than throwing, and a cell with no second line collapses to one centred line rather than leaving a gap.

Both lines truncate; neither wraps. A wrapped second line would change the row height, and in a fixed-height grid that means being cut off mid-descender instead. The accessible name carries both lines as one string, so a screen reader gets the half of the cell that disambiguates the first.

Redacting a column

For presenting and screen sharing: obscure the values in a column while everything that makes the grid readable (the row count, the sort, the filters, the column layout) stays exactly as it was. Right-click a column heading and choose Redact column.

From the API

grid.redaction.toggle('salary');
grid.redaction.set(['salary', 'bonus']);
grid.redaction.clear();          // when the call ends

This is not a security control, and the difference matters. The values are still in the model, still in the DOM, still on the clipboard and still in every export. Anyone looking at the page can read them from devtools, or by turning off a single CSS rule. What redaction defeats is a camera and a screen recorder, which is the threat a presenter actually has, and the only one it claims to answer. For a value that must never reach the browser, use permissions with writeOnly: there the value is not sent, so there is nothing to reveal.

Headings stay readable, totals do not. The column heading is left alone deliberately, a redacted column still has to be identifiable, by the presenter who wants to turn it back on and by the audience who need to know what they are not being shown. The pinned totals row is redacted, because the sum of a column is not a hint at its values: filter to one row and the total is the value.

Swapping the treatment. --lattice-redaction-filter defaults to blur(5px) contrast(0.85) and takes anything the CSS filter property does. A blur is the default because it is GPU-composited and stays cheap across a scrolling viewport; point the token at an SVG mosaic filter if you prefer classic pixellation and can afford it per cell.

Clipboard

Copy produces the tab-separated form Excel, Numbers and Sheets all read, so a range pastes as cells rather than as one lump of text. Values go through each column's clipboard hook: a lookup copies its label, a date copies an unambiguous form.

Copy and paste

grid.export.rangeText();                          // the text, without writing it
grid.export.clipboard({ rows: 'range' });         // copy the range
grid.export.clipboard({ headers: true, rows: 'selected' });

grid.edit.pasteInto(text);                        // Excel's tiling rules

Guarding against formula injection on paste

By default a copied range round-trips verbatim, so a value beginning =, +, - or @ is carried unchanged — the right thing when the paste target is another grid or cell range. When the paste target is a spreadsheet, pass sanitise: true and a leading formula character is neutralised with an apostrophe, so the value cannot be executed as a formula in Excel or Sheets.

Opt in per copy

const { buildClipboardText } = await import('../packages/core/src/export/index.js');

// An explicit cell matrix; the formula guard applies to data fields.
const cells = [['=1+1']];

// Off by default: the value round-trips exactly as copied.
const plain = buildClipboardText({}, { rows: 'range', cells });

// sanitise:true prefixes an apostrophe so a spreadsheet stores it as text.
const guarded = buildClipboardText({}, { rows: 'range', cells, sanitise: true });

return `default:${plain} guarded:${guarded}`;

Pasting follows the spreadsheet convention: one cell into a range fills the range, one row into several rows repeats down, and a block larger than the target extends past it. People have twenty years of muscle memory for this and a grid that invents its own rules is a grid people fight.

Previewing a bulk paste

A paste is the one clipboard gesture that can rewrite dozens of cells with nothing to inspect first: a payload that lands a column to the left of where it was aimed, or over a range the user forgot was selected, looks exactly like one that worked. Turn on edit.pastePreview and a paste into more than one cell opens a confirm/cancel dialog before anything commits.

Opt in

createGrid(host, {
  // Off by default: an unconfigured grid pastes straight away, as before.
  edit: { enabled: true, pastePreview: true },
});

The dialog lists every cell that will change, old → new, and every cell a commit would reject — a read-only cell, a value the column's type or edit.validate refuses, a cell a permission policy forbids. Confirm commits precisely that set through the ordinary paste path; Cancel commits nothing. A single-cell paste skips the dialog — a preview for one cell is friction, not a safety net. The dialog is a modal role="dialog": Escape cancels, Tab stays inside it, focus moves in on open and back on close, and its opening is announced through the grid's live region. The same diff is available without any UI from grid.edit.previewPaste(anchor, text, extent?), which returns { changes, rejected } and changes nothing.

Why a per-edit flag, off by default. Paste preview lives under edit because a paste is a bulk edit and its accept/reject decisions are the edit model's — the preview cannot disagree with the commit because it runs the same checks. Off by default keeps every existing grid's paste behaviour exactly as it was.

Keyboard

Press ? in the grid to see this list in the product. The overlay is generated from the same bindings the grid implements, so it cannot drift from them, and it shows Cmd rather than Ctrl on a Mac, the grid reads either, so that is what you will actually press. Escape closes it and focus returns where it was. Set shortcuts: false if you want ? for something else.

KeysDoes
ArrowsMove the focused cell.
Shift + arrowsExtend the range.
Ctrl/Cmd + Shift + arrowsOpen a second range at the focused cell, keeping the first; further presses extend it.
Ctrl/Cmd + C, V, DCopy, paste, fill down.
Delete / BackspaceClear the selected cells.
EnterStart editing; commit and step down.
TabCommit and step across.
EscapeCancel the edit; restore a maximised grid once nothing else wants it. Coming back from full screen, whether by Escape or the rail's restore button, puts focus back on the cell you were on, not on the page.
Ctrl/Cmd + FOpen the find bar; in it, Enter / Shift+Enter step through the matches and Escape closes.
SpaceToggle the row's selection.
Home / End, Page Up / DownJump; with Ctrl, to the ends of the grid.
Ctrl/Cmd + Alt + HMove focus to the column heading. On a heading, ArrowLeft / ArrowRight move between headings, Enter or Space sort by the column (Shift to add it to the sort), Alt + arrows resize, Shift + arrows move the column, Alt + ArrowDown opens the column menu, and ArrowDown or Escape return you to the data. The full list is in the accessibility guide.

None of these fire while you are typing into an input, a filter box, an open editor, the view-name field. The grid checks where the keystroke came from before claiming it, which sounds obvious and is the sort of thing that is usually wrong.

A key a heading handles acts once, on the heading: Enter sorts and does not also open an editor on a body cell, ArrowRight reaches the next heading and not a cell beneath it. A key the heading declines still travels, which is how Escape reaches a maximised grid from a heading.

Conditional formatting

A rule is a condition and the styling it produces. compileRules() turns a list into the function cell.style already accepts.

import { compileRules } from '@toclocoinc/lattice-grid';

{ field: 'margin', cell: { style: compileRules([
  { when: { op: 'lt', value: 0 }, style: { background: '#fdecea', colour: '#b91c1c' } },
  { when: { op: 'gt', value: 20 }, style: { weight: 600 }, stopIfTrue: false },
  { scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } },
]) } }

The operators are the filter's operators. gt, between and contains mean here exactly what they mean in the grid's filters. Anyone who has built a filter has already learned this, and two vocabularies for one idea is how a product ends up explaining itself twice.

First match wins, by default. "Red if overdue, amber if due this week" reads top to bottom and stops, the spreadsheet convention, and the one people expect. stopIfTrue: false lets rules combine: weight from one, colour from another.

A blank cell satisfies no comparison. Number(null) is zero, so a naive implementation sweeps every empty cell into "less than 100" and formats half a column that has no data in it.

Text still compares numerically. A rules panel produces strings, the box the user typed into yields "100", not 100, and comparing those as text puts "9" above "100".

A scale's bounds are given, not derived. Deriving them means scanning the column per cell, and a scale that rescaled as rows were filtered would change a cell's colour without its value changing, the opposite of what the colour is for.

Formatting an end user can change

compileRules() above compiles at configuration time into cell.style, which you write and a user cannot reach. grid.formatting holds the same rules as runtime state instead: the cell layer asks it on every paint, so a rule added while the grid is running takes effect on the next frame.

Rules as state

grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } });
grid.formatting.add('*',      { when: { op: 'blank' },        style: { background: '#f1f3f5' } });

grid.formatting.list('margin');        // [{ id, when, style }, …] in evaluation order
grid.formatting.move('margin', id, 0); // order decides which rule wins
grid.formatting.update('margin', id, { enabled: false });
grid.formatting.clear('margin');

Two scopes, one ordered list. A rule sits on a column id or on '*' for every column. Evaluation joins them: grid-wide first, then the column's own, so a column rule can override a grid-wide one, and stopIfTrue means the same thing across the join as it does within either half.

Saved views and undo came free. The rules are a section of GridState, and both saved views and the undo timeline are built on that. Nothing in the formatting model knows either exists.

Rules must be JSON. style cannot be a function here, because the rules are serialised into views and undo slices. Config-time cell.style still takes one, which is the right home for a rule a user should not be able to change.

Both paths coexist. Where a column has a cell.style and a runtime rule matches, the two are merged and written once; the runtime rule wins for the properties it names and leaves the rest of your styling alone.

Group rows are not formatted. A group row summarises many values rather than being an instance of one, which is the same reason decoration is dropped for it.

The panel

createGrid(el, { toolPanel: { side: 'right', panels: ['columns', 'filters', 'formatting'] } });

The panel is a form over that array and nothing more: every control is one call into grid.formatting, which is what makes each gesture undoable without the panel knowing undo exists. It exposes ordering because ordering is meaning: dragging a rule up can change which of two colours a cell takes.

Not yet in the panel: icon sets and data bars. Both are column decorations rather than cell styles, the bar and icon decorations already render them, and driving those from the panel needs a runtime column-decoration API that persists and undoes alongside the rules. Building it as a second bar implementation inside the rule engine was the alternative, and the wrong one.

Quick filter modes

One box, four ways to match: contains (the default), words, fuzzy and regex.

grid.filters.quick('acme london', { mode: 'words' });  // every term, any column
grid.filters.quick('crc',  { mode: 'fuzzy' });        // characters in order
grid.filters.quick('^CIR-[12]', { mode: 'regex' });

grid.filters.quick('acme');       // mode persists: still 'regex' here
grid.filters.quickState();        // { text, mode }

Compiled once per query, not per row. A regular expression rebuilt for each of a hundred thousand rows is a hundred thousand compiles for one keystroke. The predicate is built in the filter stage and applied to a cached text blob per row, which is why typing stays responsive at scale.

An unfinished pattern does not blank the grid. foo( is what foo(bar) looks like halfway through typing. An invalid expression falls back to a literal search, so the list stays sensible until the pattern is valid again.

Fuzzy does not rank. Subsequence matching decides what stays; it never reorders. Sorting results by match quality would fight the sort the user chose, and a filter that quietly re-sorts is worse than one that matches too much.

Permissions still apply. The text blob is built only from columns the viewer may see. A hidden column is not searchable, or the row count becomes a way to probe the value behind it.

In-cell charts

Seven chart types for a cell: line, area, column, winloss, pie, donut, bullet, stacked, range, gauge and delta. Most read an array; bullet and gauge read a number.

A trend, a mix and a target

columns: [
  { id: 'trend', field: 'readings', cell: 'line' },
  { id: 'mix',   field: 'split',    cell: { render: 'donut', props: { hole: 0.55 } } },
  { id: 'sla',   field: 'uptime',
    cell: { render: 'bullet', props: { target: 80, bands: [60, 85], max: 120 } } },

  // Two views of one field: give each an explicit id.
  { id: 'shape',  field: 'readings', cell: 'line' },
  { id: 'detail', field: 'readings', cell: 'column' },
]

A cell is a few hundred pixels seen for a second, so there are no axes, no gridlines and no legend. The chart carries one idea (a shape, a share, a comparison) and the number beside it carries the precision. Hide the number with label: false when the column next to it already says the same thing.

Pin min and max when columns are meant to compare. A sparkline scaled to its own data fills its cell whatever the magnitude, so two rows differing by an order of magnitude draw identically. Pinning the scale is what makes the column readable down its length rather than only across it.

A gap is not a zero. Entries that are not numbers break the line and omit the bar, rather than being drawn at the baseline: joining across a missing reading would draw a trend nobody measured, and drawing it at zero invents a dip.

Cost. Each chart is one SVG built once, with the paths' d attributes the only thing a repaint writes, and a bar chart is two paths rather than one element per bar. Rows recycle as you scroll, so this is what keeps a chart column the same price as a text one. Nothing measures the DOM, the drawing happens in a fixed coordinate space that CSS scales.

Accessibility. The chart is aria-hidden and the cell carries a summary: "12 points, 9 to 20, ending 18". A path cannot be read aloud, and a series announced value by value tells a listener less than the sentence does.

Profiling and statistics

grid.statistics answers questions about the rows the filters left, so every figure describes what the user is looking at rather than the whole table.

Everything worth knowing about a column, in one pass

const p = grid.statistics.profile('capacity');

p.count; p.missing; p.distinct;
p.min; p.q1; p.median; p.q3; p.max;
p.mean; p.stdDev;
p.outliers;      // by the interquartile rule
p.histogram;     // bins, ready to draw
p.alerts;        // what is worth looking at

alerts is the part that saves time: a column that never varies, a key that turns out not to be unique, a fifth of the rows missing. A profile that reports only numbers leaves the reader to notice those, and readers reliably do not.

MethodAnswers
reduceA column through any of the thirty-eight named kernels, or one of your own.
correlationPearson's r between two columns. spearman resists an outlier; kendall is tau-b.
regressionA least-squares fit of one column on another, with slope, intercept and r².
weightedAverageOne column averaged by another. weightedQuantile for the median and beyond.
seriesHow a column varies along an ordering. by is required and never guessed.
shadowWhat the grid knows about a row over time: updates, delta, rate, rank, percentile, streak.

The filters are part of the question. Every one of these reads the filtered rows. Narrow the grid and the statistics narrow with it, which is the behaviour you want when the filter is the analysis.

Kernels see arrival order, not display order. Anything order-dependent takes an explicit by rather than inferring one from the current sort, so the answer does not change when a user clicks a column header.

Process control and capability

Whether a process sits inside the tolerance it was given, and whether it is behaving or drifting. The tolerance is declared once, on the column.

The specification lives with the column

{ id: 'diameter', type: 'number', spec: { lower: 9.95, upper: 10.05, target: 10 } }

Asking for the capability

const c = grid.statistics.capability('diameter', { rules: 'nelson' });

c.cp; c.cpk;         // short-term spread, from the moving range
c.pp; c.ppk;         // overall spread
c.outOfSpec;         // parts outside the customer's tolerance
c.limits;            // { centre, upper, lower, sigma }
c.violations;        // [{ index, rule, description }, …]
c.interval;          // a confidence interval for cpk

One declared tolerance, so nothing can disagree. The indices, the charts and any conditional format all read the same spec. A tolerance passed separately to each is a tolerance that eventually differs between them, and a capability report that contradicts the cell colouring is worse than neither.

Cp and Cpk use short-term variation, Pp and Ppk overall. The first pair comes from the moving range, which is what the process can do when it is behaving; the second from the whole spread, which is what it actually delivered. Ppk well below Cpk is the signal that the process drifted rather than that it is incapable.

A baseline finds a shift instead of absorbing it. baseline: 30 fixes the limits over the first thirty readings. Limits recomputed over all the data widen to accommodate the very shift you are looking for, and then report no violation.

The point estimate alone overstates the case. A Cpk of 1.35 measured on thirty parts has a lower bound below 1.0, so a process that has "passed" a 1.33 requirement on thirty parts has demonstrated very little. interval is reported alongside it for that reason.

Drawing it

Three chart types complete the picture, and they read the same specification.

TypeShows
controlReadings against the centre line and control limits, with every rule break numbered.
movingRangeThe companion chart: variation between consecutive readings.
capabilityThe distribution against the tolerance, with a curve for each of the two spreads.

Rule breaks are numbered rather than merely marked, under Western Electric's four rules or Nelson's eight. The two sets number differently, so the chart names which it applied: a "rule 3" that could mean either is not a finding anyone can act on.

Confidence intervals

How firmly the data pins a figure down. An interval narrows as the grid does, because it describes the filtered rows and not the whole table.

A mean and a rate

grid.statistics.interval('capacity');
// { lower, upper, mean, n, confidence }, by Student's t

grid.statistics.interval('status', {
  kind: 'proportion',
  where: (v) => v === 'failed',
});
// Wilson score, which stays sensible at small n and near 0 or 1

Intervals are also available on a regression slope and on a capability index. Each uses the method that suits it: Student's t for a mean, the Wilson score for a proportion, and Bissell's approximation for Cpk.

The line the product draws. Lattice quantifies uncertainty. It does not adjudicate hypotheses: there are no p-values and no significance tests. An interval says how precisely a figure is known and leaves the judgement where it belongs. A tool that returns a verdict invites it to be read as one, and a grid is the wrong place for that.

Wilson, not the textbook formula. The normal approximation gives bounds below zero and above one at small counts, which is visibly wrong to anyone who reads it. The Wilson score stays inside the interval it is describing.

Statistic tiles

A headline figure over a grid, with the change since a baseline, a tone from thresholds, and the interval underneath.

A tile that follows the grid

import { createStat } from 'lattice-grid';

createStat({
  grid,
  container: tile,
  title: 'Total capacity',
  // A leading icon beside the title and value. Same three forms as a menu
  // item: a sprite name, a character/emoji, or your own markup.
  icon: '<i class="fa-light fa-gauge-high"></i>',
  value: { of: 'capacity', fn: 'sum' },
  baseline: (g) => lastMonth,
  bands: { good: 5000, warn: 3000, direction: 'up' },
  interval: (v, g) => g.statistics.interval('capacity'),
});

icon is optional and lays out to the side without disturbing the change indicator, threshold bands or confidence interval; omit it for the plain tile. It uses the same icon contract as a menu item — a sprite name, a single character or emoji, or author-supplied markup such as a Font Awesome glyph or an <img>.

bands and goodWhen judge different things. goodWhen says whether a rise is good news, and colours the change indicator. bands judge the value itself. They are separate because a Cpk of 0.9 is bad news whether it rose or fell to get there.

The tile follows the grid by default. Filter the grid and the figure updates. scope chooses filtered, all or selected rows; live: false detaches it and leaves refresh() to you.

value also takes show, which reports a field from the row holding an extreme rather than the extreme itself: { of: 'sales', fn: 'max', show: 'rep' } is the name of the best rep. It needs min or max, because no single row holds an average.

Formulas

A leading = in a numeric cell is a formula. Type =quantity * unitPrice and the grid stores 119.88.

What a user can type

=5 + 5
=quantity * unitPrice          // by field name
=[Unit Price] * 1.2            // by title, when it has spaces
=ROUND(quantity * unitPrice, 2)
=IF(quantity > 10, "bulk", "single")
=SUM(readings)                 // an array property on the row
=MAX(readings) - MIN(readings)

References name columns, not cells. A spreadsheet can say A1 because its rows do not move. A grid sorts, filters, groups, pages and virtualises, so the row at position 1 is a different row a moment later and a formula written against it would silently change meaning. quantity * price means the same thing wherever the row goes.

No eval, no new Function. This is text a user typed into a cell. Handing it to the JavaScript engine would let anyone who can edit a cell read your cookies, call your API with your credentials, or post the grid's contents anywhere. It is a hand-written tokeniser and recursive-descent parser, and the only callable things are the built-in functions: constructor, globalThis and constructor.constructor("return 1")() all simply fail to resolve.

The result is stored, not the expression. A formula is a way of entering a value, exactly like 1,200 or (50) or 12%. It commits as one undo step, fires one cell:changed, and passes through the column's own validation.

The result is stored, not the formula. The expression is evaluated once, at the moment you commit it, and what lands in the cell is a value like any other, so it does not recalculate when a cell it referred to changes later. For a value that must stay in step with its inputs, use a computed column, which is re-evaluated whenever its dependencies move.

Adding your own functions

createGrid(el, {
  formulaFunctions: {
    MARGIN: ([revenue, cost]) => (revenue - cost) / revenue,
    BAND: ([value]) => (value > 1000 ? 'A' : 'B'),
  },
});

// Or evaluate one yourself, anywhere.
import { evaluateFormula } from '@toclocoinc/lattice-grid';
const r = evaluateFormula('=a * b', { data: { a: 6, b: 7 } });
r.ok ? r.value : r.error;   // 42

A formula that cannot be read rejects the edit and the cell keeps its old value, the same as any other unparseable text. Failures are returned rather than thrown: this runs on the commit path, where an exception would abandon the commit half-done.

Bare arithmetic is deliberately not a formula. 2-1 is a plausible product code and 1/2 a plausible date. A reader that evaluated either on a guess would have to guess wrong sometimes, and the wrong answer is not a visible error but a plausible number: 2*3 stored as 23 looks like data. Arithmetic without a leading = is refused outright, so the cell keeps what it had rather than taking a number nobody typed.

Your own menu items and buttons

The cell menu's function form is handed the cell that was clicked and the built-in items. Adding one entry does not mean reproducing the other thirteen.

An item that acts on the cell it was opened on

createGrid(el, {
  contextMenu: (params, defaults) => [
    ...defaults,
    { separator: true },
    {
      name: `Open ${params.value} in CRM`,
      action: (ctx) => window.open(`/crm/${ctx.data.accountId}`),
    },
  ],
});

params and the action's argument are the same shape: { key, colId, value, row, data, column, index, grid }. data is your original row object, so an item can reach fields the grid never displayed.

An item with your own icon

createGrid(el, {
  contextMenu: (params, defaults) => [
    ...defaults,
    { separator: true },
    // A registered sprite name — the built-in items use these.
    { name: 'Download', icon: 'download', action: () => save(params.data) },
    // A single character or emoji, rendered as text.
    { name: 'Star', icon: '★', action: () => star(params.data) },
    // Your own markup — a Font Awesome glyph, an inline SVG, an image.
    // It is inserted into the icon slot as an element, at the same trust
    // as the item's action, and never into the label.
    { name: 'Export', icon: '<i class="fa-light fa-file-export"></i>', action: exportRow },
  ],
});

A MenuItem's icon accepts three forms, told apart automatically so existing definitions keep working: a registered sprite name ('download'), a single character or emoji ('↑'), or author-supplied element markup ('<i class="fa-light fa-download"></i>'). Markup is rendered as an element rather than shown as text — the misbehaviour it replaces — and is written only into the icon slot, so a definition can never inject markup into the label. It is trusted like the item's action: a menu definition is code you wrote, not user data.

Handed the defaults, rather than replacing them. A builder that had to return every item in order to append one would be written once as a copy of the built-ins and would then drift from them, the copy keeps the menu it was forked from, and stops gaining whatever the grid adds later. Spreading defaults costs one line and never goes stale.

Return the array you want shown: add, remove, reorder, or replace outright. An empty array suppresses the menu deliberately. Returning nothing leaves the defaults alone, because a missing return is a typo and deleting the whole menu is a harsh reading of one.

Declaring a menu on the column itself

A cell menu can also be declared on the column, with contextMenu on the column definition. It takes the same shapes the grid-level option takes, plus a bare array for the common “just these items here” case: boolean | MenuItem[] | (params, defaults) => items.

Each column's menu logic beside the column it is about

createGrid(el, {
  columns: [
    { field: 'account' },
    // Just these items, here.
    { field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },
    // Or the built-ins plus one, the same form the grid-level option takes.
    {
      field: 'amount',
      contextMenu: (params, defaults) => [
        ...defaults,
        { separator: true },
        { name: `Reprice ${params.value}`, action: (ctx) => reprice(ctx.data) },
      ],
    },
    // And nothing at all on a column nobody should act on from here.
    { field: 'nationalId', contextMenu: false },
  ],
});

This adds no power the grid-level option did not have — params.colId and params.column always let one callback branch by column. What it adds is locality: the menu for a column is declared where the column is, instead of collecting into one growing switch a long way from the thing it is about.

The three levels compose as a chain

The built-in items go in first, then the grid-level contextMenu, then the column's own — each handed the previous level's result as its defaults. A column that wants one extra item writes one extra item; it never has to restate Paste, Clear and Fill down, nor whatever the grid-level builder just added.

Built-ins → grid → column, executed

const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { root } = createTestDom({ width: 600, height: 300 });

let handedToTheColumn = [];
const grid = createGrid(root, {
  rowKey: 'id',
  rows: [{ id: 1, account: 'Acme', amount: 120 }],
  columns: [
    { field: 'account' },
    {
      field: 'amount',
      contextMenu: (params, defaults) => {
        handedToTheColumn = defaults.map((d) => d.name);
        return [...defaults, { name: 'from the column', action() {} }];
      },
    },
  ],
  contextMenu: (params, defaults) => [...defaults, { name: 'from the grid', action() {} }],
});
flushFrames();

const row = grid.rows.get(0);
grid.emit('cell:contextmenu', {
  row, key: row.key, index: 0, colId: 'amount',
  column: grid.columns.get('amount'), value: 120,
  event: { clientX: 10, clientY: 10, preventDefault() {} },
});
flushFrames();

// The column builder was handed the grid builder's output, not the raw
// built-ins: 'from the grid' is already in its `defaults`.
const chained = handedToTheColumn.includes('from the grid');
const shown = [...root.querySelectorAll('.lat-menu__item')]
  .map((i) => String(i.textContent).trim());
grid.destroy();
return chained && shown.includes('from the column') ? 'grid|column' : 'broken';

Suppression follows the same order, and the more specific level wins. contextMenu: false on a column is a statement about that column and no other. Equally, a column may declare a menu on a grid whose contextMenu is false — which is how you say “no menu anywhere except here”. contextMenu: true on a column means “whatever came before”, so it restores the built-in menu on a grid that turned it off.

Grid levelColumn levelWhat opens on that column
not setnot setthe built-in menu
a buildernot setthe builder's result
a buildera builderthe column's builder, handed the grid builder's result
a builderan arraythe array — the grid level still ran, and was replaced
a builder, or not setfalsenothing — the column suppresses, and no other column is affected
falsenot setnothing — the grid-level off stands, as it always has
falsean arraythe column's array. The column opts back in: grid-level false is a default, not a lock
falsea builderthe builder's result, handed the built-in items as its defaults. The column opts back in
falsetruethe built-in menu. The column opts back in and asks for the defaults

contextMenu: false on the grid is a default, not a lock. If you set it as a safety property — a read-only grid, a screen where nobody should be able to copy or clear from a right-click — be aware that a column declaring its own contextMenu will still open one, because the more specific level wins in both directions. That is deliberate: a read-only grid with one actionable column is a real shape, and it is the only way to say “no menu anywhere except here”. But it does mean grid-level false does not guarantee that no cell menu can open anywhere — only that none opens unless a column asks for one. If you need the absolute guarantee, do not declare contextMenu on any column.

A chain, not a replacement. If the column level replaced the grid level, every column that wanted one extra item would have to restate everything the grid-level builder does — and would then stop tracking it the first time it changed. This is the same rule columnMenu already follows for the header: you are handed what came before so you can add to it rather than reproduce it.

A range, and rows that belong to no column

On a multi-column selection, the column you right-clicked decides. Not the intersection of the selected columns' menus, which silently drops items; not their union, which offers actions that are wrong for most of the selection. The clicked column is the one the user pointed at, and it is the one that answers — the built-in range actions (Copy, Clear, Fill down) still act on the whole range as they always did.

A row with no owning column falls back to the grid-level menu. Group rows, pivot group rows and full-width rows do not belong to one column, so there is no column-level declaration to consult; the chain simply has one fewer link and the grid-level menu stands. Nothing errors and nothing silently shows an empty menu.

Every route honours the column: a right-click, and the keyboard's Shift+F10 or Context Menu key on the focused cell. The menu is a role="menu" of role="menuitem"s that takes focus and closes on Escape wherever it was opened from.

Trust is unchanged. A MenuItem is the same object it always was, including icon markup being trusted at the same level as action. Declaring one on a column changes where it is written, not who is trusted to write it: a column definition is your code, exactly as a grid config is.

A column preset or columnDefaults may supply contextMenu too, and the column's own declaration outranks both — so a house rule like “no cell menu on anything tagged sensitive” is written once.

columnMenu takes the same function form, for both routes into a column's menu: the header's 3-dot button and a right-click on the heading. Its params is { colId, column, grid }, and the same rules apply: spread the defaults, return an empty array to suppress, return nothing to leave them alone.

An item that appears on some columns and not others

createGrid(el, {
  columns: [{ field: 'jan', title: 'Jan', context: { month: 1 } }],
  columnMenu: (params, defaults) => {
    // Your own keys are on the definition you wrote.
    const month = params.column.def.context?.month;
    if (!month) return defaults;
    return [...defaults, { separator: true },
      { name: 'Select quarter', action: () => selectQuarter(month) }];
  },
});

Your properties are on column.def, not on the column itself. column is the grid's resolved interpretation of your definition and carries only keys the grid understands; column.def is the object you wrote, untouched. Keeping them apart means an application property can never collide with one the grid adds in a later version, and you do not have to maintain a lookup table keyed by column id alongside the columns themselves.

Chart a selected range

rangeChart turns a selected cell range into a chart — the spreadsheet gesture. It is off by default; set it and the cell menu offers Chart selection, with Alt+F1 as the keyboard route, whenever the selected range has a numeric column to plot. The leading text column becomes the categories and the numeric columns beside it become the measures; a hidden or unreadable column is never charted, and the chart is bound to the band of rows the rectangle covers.

The DOM layer draws no charts — the charts module is optional and the page loads it — so rangeChart carries the handler that draws. A function, or an object with onChart, is called (grid, range); it typically calls chartRange from modules/charts, which derives the chart from the range and returns the live Chart.

Wiring the gesture to the charts module

import { chartRange } from '@toclocoinc/lattice-grid/modules/charts';

createGrid(el, {
  columns, rows,
  rangeChart(grid, range) {
    // One numeric column → a bar; several → a grouped bar. Null when the
    // range has nothing to measure, so guard before using it.
    const chart = chartRange(grid, { container: '#chart', range });
    if (chart) chart.update({ scheme: 'colourblind' });
  },
});

Why a handler rather than a flag that just draws. The charts module is optional by design — a page that never charts never loads it — so the DOM layer cannot draw a chart itself without pulling the whole drawing surface into every bundle. Handing the drawing back to the page keeps that promise, and it is the same seam createChart already uses: the grid is handed to the charts module, never imported by it.

A button of your own on the rail

createGrid(el, {
  toolPanel: {
    side: 'left',
    // A string names a built-in; an object is yours. Order is respected, so
    // yours can sit between built-ins rather than only after them.
    actions: ['undo', 'redo', {
      name: 'sync',
      title: 'Sync to the server',
      icon: 'restore',
      run: ({ grid, keys, cells }) => api.sync(keys),
      enabled: () => grid.state.modified(),
    }],
  },
});

title and icon may each be a function, re-read on every repaint, for a control whose meaning changes: that is how maximise becomes restore. enabled is a predicate rather than a flag, so a button that cannot do anything greys itself out instead of doing nothing when clicked.

The left rail

toolPanel: { side: 'left' } docks the panels as an icon rail and turns on the action buttons: undo, redo, export to CSV, export to Excel, copy to the clipboard, print, restore the default view, and maximise. Each is a single click on the thing you came for, and all four export destinations are in the cell context menu as well.

Picking a subset

createGrid(el, {
  toolPanel: {
    side: 'left',
    panels: ['columns', 'filters', 'views', 'quick'],
    // Omit `actions` entirely to take all seven, including any added later.
    actions: ['undo', 'redo', 'export', 'excel', 'clipboard', 'print', 'restore', 'maximise'],
    exportName: 'circuits',
  },
});

An explicit actions array replaces the default rather than extending it. That is what makes it useful (you choose the set and the order) but it also means a config written against an earlier version keeps exactly the buttons it named and silently misses anything added since. If you want whatever the current version offers, leave the key out.

Every action is gated on a predicate rather than always offered, so undo greys out when there is nothing to undo and "restore the default view" greys out when nothing has changed. A rail of seven icons where three do nothing is worse than a rail of four.

Maximise

A grid usually lives in whatever box the page layout gave it, and that box is usually too small for the job. The left rail's last button fills the browser window with the grid, and clicking it again puts the grid back exactly where it was. Esc also restores.

Your own control, or a keyboard shortcut

// The rail button is on by default. This is the same thing.
grid.maximise.toggle();
grid.maximise.active();   // true while it fills the window

document.addEventListener('keydown', (e) => {
  if (e.key === 'F11' && !e.ctrlKey) { e.preventDefault(); grid.maximise.toggle(); }
});

// Or take the button away, if the application has its own full-screen mode.
createGrid(el, { maximise: false });

One button in two states rather than two buttons: the icon and its label turn round when the grid is maximised, so the control always describes what it is about to do.

The element is moved, not just restyled. A position: fixed element is positioned against the nearest ancestor carrying a transform, filter, contain or will-change, which is to say any card, any animated panel, any sticky app shell, and against the viewport only when there is no such ancestor. A class alone would therefore fill the window on one page and land in a 300px box on the next, and would still be clipped by an overflow: hidden or buried by a stacking context. Reparenting to <body> removes every ancestor that could do any of that.

Coming back is a hidden placeholder left in the element's place, not a remembered parent and index, an index goes stale the moment your application inserts a sibling while the grid is away, and then silently reinserts in the wrong slot. The placeholder also holds the vacated space open at the size the grid had, so the page behind neither reflows nor loses its scroll position while you are looking at the grid.

The geometry is written as inline styles and every displaced property is handed back exactly as it was found, because the element being restyled is yours: most often one with an inline height on it, which is the ordinary way a grid gets sized and which nothing but an inline style can beat. While maximised, the element carries .lat-maximised and <body> carries .lat-maximised-host, as hooks for your own CSS.

Highlighting

One mechanism for two jobs: the flash a changed cell makes, and a marker you paint deliberately.

On change, and on demand

highlightOnChange: { colour: '#ffe08a', duration: 1200 },

grid.highlight({ key: 'r1', colId: 'cap' }, { colour: 'green', duration: 800 });
grid.highlight({ key: 'r3' },      { colour: '#fdeaea', duration: 0 });  // until cleared
grid.highlight({ colId: 'margin' },{ colour: '#e7f1fd', duration: 0 });
grid.highlight.clear({ key: 'r3' });
grid.highlight.clear();

Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over it. A highlight belongs to the row rather than the element, so it survives scrolling, sorting and paging.

Find

The quick filter answers "show me only the rows that contain X". Find answers a different question — "where is X?" — and leaves every other row exactly where it was, so you keep your place and the neighbours that give a value its meaning. Press Ctrl+F (Cmd+F on a Mac) with focus in the grid: a bar opens above the header with focus in its input, every cell whose displayed text matches lights up in place, the bar reads "N of M", Enter and Shift+Enter step through the matches with wrap, and Escape closes it and clears the marks.

The same thing from code

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
  rowKey: 'id',
  find: { shortcut: true, debounce: 0 },   // the bar's settings; `find: false` removes the bar
  columns: [
    { field: 'name' },
    { field: 'price', format: (p) => `$${p.value.toFixed(2)}` },
  ],
  rows: [
    { id: 'a', name: 'Acme', price: 3.5 },
    { id: 'b', name: 'Beta', price: 13.25 },
    { id: 'c', name: 'Acme Two', price: 3.75 },
  ],
});
let events = 0;
grid.on('find:changed', () => { events += 1; });

const count = grid.find('$3.');            // the formatted text: two prices begin "$3."
const first = grid.find.current();          // { key: 'a', colId: 'price', index: 0, pinned: null }
grid.find.next();                           // row c; next() again wraps back to a
const rowsStillShown = grid.rows.count();   // 3 — find never removes a row

grid.destroy();
return `${count.total} matches, ${rowsStillShown} rows shown, current ${first.key}, events ${events > 0}`;

grid.find(text, opts) searches now and returns a FindCount; it is callable like grid.highlight. The options are a FindQuery: caseSensitive, wholeCell, columns (an id or a list of ids; omitted searches every visible column) and from (the display index the first current match is chosen at or after). Then find.next(), find.prev() and find.goTo(i) move the current match and scroll it into view; find.matches(), find.count(), find.current() and find.state() read the result; find.open(text?), find.close() and find.clear() drive the bar; find.stateFor(key, colId) is what the painter asks. Every change fires find:changed with the query, the open flag and the count.

RuleWhat it means
Display textFind matches what the cell shows — a column format, a unit type, a lookup label — never the raw value. Searching $3. finds prices formatted that way. There is no regular-expression mode; the quick filter has one.
An overlay, not a filterNo row is reordered, removed or edited. Matches are painted as .lat-cell--find, the current one also as .lat-cell--find-current, coloured by --lattice-find-match and --lattice-find-current. Find and the quick filter coexist: both may be active, and find re-runs over whatever the filter leaves.
Pinned rows and columnsRows pinned to either edge (and a bottom grand total) are searched and painted like any other; a pinned match has index: -1 and pinned: 'top' | 'bottom'. Pinned columns are cells like any other.
Virtualised rowsMatches are computed from the row model, not the DOM, so a match five thousand rows down is counted without rendering it; stepping to it scrolls it into view, and the paint follows the render.
The active cellStepping to a match makes it the active cell, so Enter in the grid edits it — except while an edit is already open, when the match is scrolled and painted and the editor is left alone.
Windowed sourcesA paged pushdown source (OData, DuckDB, REST) holds only its loaded rows client-side, so only those are searched. The count says so — "N of M in loaded rows", and FindCount.windowed is true with loaded and rows beside it — rather than presenting a page-one count as the whole. Pushing find to the adapter is a follow-up, not a v1 promise.
Large gridsTyping is scanned in per-frame slices from the row at the top of the viewport, so the matches on screen appear after the first slice and the grid stays interactive; the count reads "N of M so far" and count.complete is false until the scan finishes. grid.find(text) scans to completion before returning, so its answer is final.
The browser's findCtrl+F is claimed only while focus is inside the grid and not in a text field, so the page's own find works everywhere else and an open cell editor keeps it. find: { shortcut: false } leaves the binding to the page and keeps the bar reachable through find.open(); find: false removes the bar altogether.
AccessibilityThe bar is a role="search" landmark; every control is a native input, button or select with a catalogue name, so nothing needs a mouse. The count is announced through a polite role="status" line once per completed search ("3 of 12 matches", "No matches in loaded rows"); the current match becomes the focused cell when no edit is open, so a screen reader reads it. The strings are in every bundled locale.
Keys in the barOnly Escape and Enter in the input are consumed by the bar. Everything else — Tab between its controls, Enter and Space on its buttons, a page's own Ctrl+S — propagates as it would from any form control, so a host's document-level shortcuts still see it; the grid's own keyboard and range layers stand aside for a key aimed at the bar, which is what keeps Tab from being read as "next cell" and Enter from opening an editor.
Pinned stripsStepping to a match scrolls it fully into the part of the body the pinned strips do not cover — below pinned-top rows and sticky group headings, above pinned-bottom rows and a bottom grand total. That is grid.scroll.toRow's behaviour for every caller, not only find.

Configuration

find: false                              // no bar, no Ctrl+F; grid.find(text) still works
find: { shortcut: false }                // bar via grid.find.open() only
find: { debounce: 250 }                  // a slower typist, or a slower grid

grid.find('acme', { caseSensitive: true, wholeCell: false, columns: ['customer'] });
grid.find.count();     // { current, total, complete, windowed, loaded, rows }
grid.on('find:changed', (e) => status.textContent = `${e.count.current} of ${e.count.total}`);

Saved views

A view is a named grid state: sort, filters, grouping, column order, widths, visibility. There are two kinds and the picker keeps them apart.

Views you ship, and views the user saves

views: {
  saved: [
    { id: 'escalations', name: 'Escalations',
      description: 'Escalated circuits, worst SLA first',
      state: {
        filters: { col: 'statusId', op: 'eq', value: 4 },
        sort: [{ col: 'utilisation', dir: 'desc' }],
      }},
    { id: 'commercial', name: 'Commercial', isDefault: true,
      state: { columns: [{ id: 'notes', hidden: true }] }},
  ],
  allowSave: true,
}

Views in saved are defined views: part of the application, listed under their own heading, and neither renamable nor deletable: refused by the model as well as hidden in the interface. A view flagged isDefault is applied on load. Everything the user saves sits below, with rename, share, make-default and delete.

Applying a view is a destination, not a patch. A view's state names only the sections it cares about, so applying one resets to the grid's starting state first. Without that, clicking "APAC capacity" after "Commercial" would inherit Commercial's hidden columns, the same view giving a different grid depending on what preceded it, which is the one thing a named view must not do.

When the columns change underneath a saved view

A saved view is user data written months ago against a column set that has since moved on. A release adds columns, renames one, drops another; the views people saved must survive it.

What changedWhat a saved view does
A column was addedIt appears, in the state its definition declares, placed after every column the view names. A view is not a whitelist, it says nothing about columns it has never seen, and silence is not an instruction to hide.
A column was added, and should not appear yetDeclare it layout: { hidden: true }. The view does not mention it, so nothing overrides that, and it stays hidden until the user shows it.
A column was removedThe entries naming it are skipped and reported; the rest of the view applies. A sort or grouping on the missing column is dropped rather than left pointing at nothing.
A column was renamedThat is a removal and an addition. The old id is skipped, the new column appears at the end, and any width or pinning the user had set is lost with the old id.

Applying a view never throws and never refuses. It returns a report, { applied, skipped }: naming each thing it could not use and why. Refusing the whole view because one column has gone would lose a layout the user built deliberately, and throwing during a page load would lose the page. So a view degrades to as much of itself as still makes sense, and the host decides whether the user needs telling.

Telling the user their view has aged

const report = grid.state.apply(saved.state);
if (report.skipped.length) {
  // e.g. [{ key: 'columns.legacyRef', reason: 'unknown column' }]
  notify(`This view was saved against an older layout; ${report.skipped.length} setting(s) no longer apply.`);
}

The consequence worth planning for is the first one: a column added in a new release is visible to everyone, including users with a saved view. That is usually what you want (a new field nobody can see is a field nobody uses) but if a release adds several at once, every saved view gains them all at the right-hand end. Ship them hidden if that is not the introduction you want.

Persisting them

The grid makes no network calls. It tells you what happened and you decide what that means.

To a server

grid.on('view:saved',   e => api.post('/views', e.view));
grid.on('view:renamed', e => api.patch(`/views/${e.view.id}`, { name: e.view.name }));
grid.on('view:removed', e => api.delete(`/views/${e.view.id}`));
grid.on('view:default', e => api.patch(`/views/${e.view.id}`, { isDefault: true }));

Each event carries the one view that moved, so you send a single record rather than diffing two lists. Since the grid does not track whether your write landed, catch the failure and call grid.views.reload().

With no backend at all

createGrid(el, {
  views: { local: true, allowSave: true },
});

The other half of the same seam. views.storage above is where a developer plugs in their own backend, a real server, reached over the network. Not every grid has one to plug in, and a picker offering "Save" that quietly does nothing until a backend exists is worse than not offering it. views.local: true is the no-backend answer: saved views live in this browser's own localStorage, under a default key shared by every grid on the origin unless you pass one of your own, views: { local: { key: 'orders-grid-views' } }: to keep two grids' views apart. Given alongside an explicit storage, the explicit adapter always wins and local is silently (well, not silently: it warns once) ignored, so a page cannot end up writing to both without meaning to. The adapter itself is exported as createLocalViewStorage(opts), for anyone who wants it directly, a custom key without the shorthand, or a different Storage-shaped backing such as sessionStorage for views scoped to one tab rather than persisted across visits.

Undo

Undo covers the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied view and a restore all record an entry, and each carries a label written for a button.

Naming the action

grid.history.peek('undo');   // { type: 'sort', label: 'sort by Region' }
grid.history.undo();
grid.history.list();         // the timeline, newest first

A button that says only "Undo" makes the user press it to find out what it does: and pressing it is the thing they were unsure about. "Undo sort by Region" is decided before the click rather than after.

Grouping is by user action, not by internal operation. A multi-cell paste is one entry, not one per cell. An AI plan is one entry however many actions it contains.

Grouping your own changes

grid.history.transaction('apply the quarterly template', () => {
  grid.sort.set([{ col: 'margin', dir: 'desc' }]);
  grid.filters.set({ col: 'region', op: 'eq', value: 'EMEA' });
  grid.columns.hide(['notes', 'mgmtIp']);
});
// one press of undo reverses all three

Column permissions

Four levels, resolved per column from configuration or a callback. They are not a ladder, reading and writing are independent, so they are the four corners of a 2×2.

LevelVisibleReadableEditableFor
hidden, , , Absent from the grid, the tool panel, exports, the clipboard, saved state and the filter model.
readyesyes, No editor opens; paste, fill and clear skip it.
writeOnlyyes, yesA secret. The cell shows a mask, the editor opens empty.
writeyesyesyesThe default, so the feature is opt-in.

Every accepted form

permissions: 'read'                                   // blanket
permissions: { salary: 'read', ssn: 'hidden' }        // '*' sets the default
permissions: (column, ctx) =>
  ctx.context.role === 'admin' ? 'write' : 'read'

permissions: {
  default: 'read',
  columns: { name: 'write' },
  resolve: (column, ctx) => ctx.context.role === 'admin' ? 'write' : undefined,
}

grid.permissions.setContext({ role: 'clerk' });        // re-resolves everything

For three of the four this is a usability control, not a security boundary. Anything the grid can render it has already loaded, and devtools reaches it. Hiding a column removes it from the interface, not from the process, which is worth a great deal for the way data actually leaks, which is an export mailed onward or a shared view carrying a column a colleague should not see. It is worth nothing against someone determined.

writeOnly is the exception, and the reason it exists. Nothing in the grid needs the value, so your server can send null for that field and the column still works: at which point the secret is genuinely not on the page. Enforce everything else server-side; permittedColumns and permittedExport are pure and dependency-free so the same policy object runs in Node against a request that arrived over the wire.

Audit mode

Give the grid a prior snapshot and every row reports whether it was added, removed or changed, and which cells moved.

Before and after

diff: { snapshot: lastApprovedVersion },

grid.diff.summary();              // { added, removed, changed, unchanged }
grid.diff.statusOf('CIR-100042'); // 'changed'
grid.diff.changedColumns('CIR-100042');
grid.diff.before('CIR-100042', 'capacity');

Changed rows get a band down the leading edge and changed cells a tint with the prior value on the cell as data-before. The row band and the cell tint are deliberately different devices: "which rows moved" and "what changed in this row" are different questions and one highlight cannot serve both.

A network map: icon nodes, links coloured by their value

A network chart draws the rows as a graph — two columns name the endpoints and a third carries the value on the link. Until 1.64 every node was a plain circle, every edge the same grey, and the layout went wherever the simulation put it, so the picture a network team actually draws — core on top, regions below, an icon per device, a colour per circuit — could not be expressed. Three options change that, and every one of them carries a fact that only the host has.

One nodes list, one rule set, one chart config

createGrid(el, {
  columns: [{ field: 'from' }, { field: 'to' }, { field: 'load', type: 'number' }],
  rows,                                  // one row per circuit
  selection: 'multiple',
  // The rules live on the column. The cells and the links read the same ones.
  formatting: {
    load: [
      { label: 'Healthy',   when: { op: 'lt',  value: 40 }, style: { background: '#107c41' } },
      { label: 'Busy',      when: { op: 'lt',  value: 80 }, style: { background: '#f0b400' } },
      { label: 'Saturated', when: { op: 'gte', value: 80 }, style: { background: '#a4262c' } },
    ],
  },
});

createChart({
  grid, container: '#topology', type: 'network',
  source: 'from', target: 'to', y: { col: 'load', fn: 'sum' },
  selection: true,
  nodes: [
    // x/y are fractions of the plot: both given pins the node there.
    { id: 'core-1', label: 'Core', icon: 'router', x: 0.3, y: 0.15 },
    { id: 'core-2', label: 'Core', icon: 'router', x: 0.7, y: 0.15 },
    { id: 'emea',   label: 'EMEA', icon: 'hub',    x: 0.2, y: 0.8 },
    { id: 'amer',   label: 'AMER', icon: 'hub',    x: 0.5, y: 0.8 },
    { id: 'apac',   label: 'APAC', icon: 'hub',    x: 0.8, y: 0.8 },
  ],
});

The icons come from the grid, not from the chart. icon is a name in the grid’s own sprite registry — a built-in, or one you registered — and the chart reads it through grid.icons, the grid it is bound to. That is deliberate and it is the only route that works: the charts module ships as its own bundle, so an import of the registry there would hand the chart a second, empty copy, and a glyph you registered would be invisible to it. One registry, reached through the one object both sides already share. Unknown names warn once and draw a plain disc rather than nothing.

Pinning steers the layout without replacing it. A node with both x and y is a fixed body: it still pushes its neighbours apart and still pulls on its links, and the integration step skips it. Everything unpinned settles around it by the same deterministic relaxation as before — and pinning one node does not reshuffle the others, because the layout’s seeding draws for every node whether it is pinned or not, precisely so that it cannot. Half a position is not a position: a node with only x is laid out.

Parallel links are not summed. Three rows between the same pair are three lines, offset 4 px apart, symmetric about the pair’s own line, in row order. One line carrying their total would be a number nothing measured — three circuits at 40% do not make one at 120% — and the pointer picks out the line you are over rather than the pair, so each one’s own value is readable. Links are undirected: no arrowheads, and A,B is the same pair as B,A.

There is no chart-level threshold option, on purpose. The colour comes from grid.formatting.styleFor(col, value)background, then backgroundColor, then color; a gradient is not a colour and is not read. A second place to say “red above 80” is a second place for the chart and the cell to disagree. The legend lists only the rules that fired, with their own labels and swatches, and changing a rule recolours the links on the next frame without the layout re-running, so nothing moves.

Clicking acts on rows, because a link is a row. With selection: true, clicking a link selects its row and clicking a node selects every row it is an end of; the grid’s selection then lights those marks and dims the rest. The types whose marks are aggregates are deliberately untouched: selecting the forty rows behind a bar is not what a click on a bar means.

The glyphs those nodes draw are yours to supply. icons on the grid configuration registers SVG sprites by name, before the first paint, into the same registry the built-in chevrons and sort arrows live in — so a name you register is usable anywhere a glyph name is: a column’s icon decoration, a rail action’s icon, a network node’s icon. Registering a built-in name overrides it, which is how the expander chevron becomes your own mark. A sprite is a view box and its path data; one filled path on the house 16×16 box is the common case, so a bare path string is read as exactly that. grid.icons reads the registry back.

Registering your own glyphs, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createGrid } = await import('../packages/dom/src/index.js');

const grid = createGrid(root, {
  rowKey: 'id',
  rows: [{ id: 'core-1', load: 41 }],
  columns: [{ field: 'id' }, { field: 'load', type: 'number' }],
  // Your own sprites, by name, alongside the built-in set.
  icons: {
    router: { viewBox: '0 0 16 16', paths: ['M2 6h12v6H2Z', 'M5 6V3h6v3'], paint: 'stroke' },
    hub: 'M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Z',      // one filled path is enough
  },
});

// Read back what the registry holds: the normalised glyph, whichever form registered it.
const router = grid.icons.get('router');
const hub = grid.icons.get('hub');
grid.destroy();

return `router ${router.paths.length} ${router.paint} | hub ${hub.paths.length} ${hub.paint}`;

Showing what was deleted

A deletion is a change too

diff: { snapshot: lastApprovedVersion, removedRows: 'pinned' }   // or 'data'

The row is gone from the data and still in the snapshot. That is the only place it exists, and it is what these rows are built from, so they carry the values they had when the snapshot was taken, not any current ones.

One option answers both questions: whether to show it, and whether it counts. 'pinned' puts it beneath the rows, struck through and dimmed, outside the row set, so rows.count(), exports and selection all pass over it. 'data' appends it to the set instead, and all three include it. Omitted, nothing is shown and the grid is the one you already had.

Neither sorts or filters it among the live rows. A removed row's values are yesterday's; ordering them among today's presents two data sets as one, and lets a filter written for current values decide the fate of historical ones. Neither permits an edit either, a write aimed at a removed row is refused and returns 0, rather than being counted as applied against a record that is not there.

Driving the grid with a model

The grid describes its own columns and operators, you send that to whichever model you like, and it validates the reply before anything is applied. It makes no network call and has no default provider.

The loop

ai: {
  async ask({ message }) {
    const res = await yourModelClient.complete({
      model: 'your-model-of-choice', messages: [{ role: 'user', content: message }],
    });
    return res.text;
  },
}

That mounts a prompt bar. The user types "EMEA circuits over 500 gigs, biggest first"; the grid composes a message including its schema; your callback returns the model's reply; the grid validates it and shows what it would do in plain English, "Filter Region is EMEA and Capacity is more than 500, sort Capacity descending", with Apply and Discard.

Nothing is executed on trust. The vocabulary is seven actions, setFilters, setSort, groupBy, showColumns, hideColumns, setQuick, clear, and a reply naming a column that does not exist is rejected with a reason while the valid actions in the same reply are kept. A model cannot be talked into an operation the vocabulary does not contain, because there is nothing else to call.

Applying is one undo entry, labelled with what it did. docs/AI-SKILL.md is the reference to hand your model.

Export

CSV, Excel, clipboard, print

grid.export.csv({ download: true, fileName: 'circuits' });
grid.export.excel({ download: true, sheetName: 'Circuits' });
grid.export.clipboard({ headers: true, rows: 'range' });
grid.export.print();

Exports follow what the user is looking at, the current filter, sort and column order, formatted values, and only the columns they may read.

On the server

A user asks to export half a million rows. You can ship them all to the browser to be formatted, or write the export server-side and watch it drift from what the grid shows. The headless core is the third option.

The same code, in Node

import { createHeadlessGrid } from './lattice-core.esm.js';

const grid = createHeadlessGrid({ columns, rows: fromDatabase });
grid.state.apply(savedView.state);
return grid.export.csv();

A saved view is a serialisable state object, so the server applies exactly what the user set up, using exactly the code the browser uses. The £1,234.50 in the file is the £1,234.50 on the screen because it came out of the same formatter. 50,000 rows filtered, sorted and written to CSV takes about 100ms.

Import

The mirror of export: rows coming in from a CSV or TSV file, a drop, or a pasted spreadsheet block. The pipeline is parse, infer each column's type, map the columns onto your fields, then preview and confirm — nothing lands until the user says so.

Preview, then confirm

// Parse and map without touching the grid — this is what the dialog shows.
const preview = grid.import.preview(csvText);
// preview.columns is the mapping the user can edit; preview.warnings flags a
// ragged file. Confirming appends (or, with { mode: 'replace' }, swaps).
grid.import.apply(preview);

Set config.import to true to add the DOM affordances — a "Import rows from CSV…" cell-menu item, a file drop target, and paste — each opening the same preview dialog. The grid.import API is present whether or not you do, so a server-side or headless import runs the identical pipeline. .xlsx is not read (it needs an inflate and XML reader the zero-dependency envelope does not carry); save the sheet as CSV.

Options and calls you may not have met

The rest of the surface, in one place. Each of these is documented in full in the reference; this is here so that reading the guide end to end leaves nothing you have never heard of, which is the difference between a guide and a tour of the parts we found most interesting.

Configuration

NameWhat it does
autoHeightautoHeight.
bucketFnReplace the built-in bucketing entirely. (optional)
coalesceMs(optional)
columnGroupsHeader grouping declared separately from the columns.
columnPresetsNamed bundles applied with preset: 'money'.
columnVirtualisationAboveColumn count above which columns virtualise too.
delimiter(optional)
enterMovesDown(optional)
fillHandle(optional)
granularityhour &hellip; year. Chosen from the span when omitted.
groupFooterA closing total row per group.
groupSelectsChildren(optional)
groupSelectsFiltered(optional)
headerHeightPer header row.
historyBarA standalone undo/redo toolbar with a timeline.
hostFilterAn application-level predicate composed with the grid's own filters.
idleMsSilence after which a peer is shown idle. (optional)
indexLimitCell descriptors held before the oldest are dropped. (optional)
lineEnding(optional)
lockMsSilence after which a peer's edit claim is disregarded. (optional)
maxCachedPages(optional)
maxDecimals(optional)
me(read-only)
minDecimals(optional)
overscanRows rendered beyond the viewport.
pageSizes(optional)
pinnedBottomRowsAs pinnedTopRows, held below the body instead. Sits under the grand total when both are shown.
pipesTemplate pipes for cell.template.
processCell(optional)
promoteToMemoryBelow(optional)
quickFilterTextInitial quick-filter term. Equivalent to grid.filters.quick(text).
findThe in-grid find bar (Ctrl+F): false removes it, { shortcut, debounce } tunes it. See Find.
quote(optional)
removeMsSilence after which a peer is dropped. (optional)
showHeaderDraw the column headings at all. false removes the row, and removes it from the accessibility tree rather than only from view. Distinct from showColumnFunctions, which keeps the headings and drops only their sort, filter and menu controls.
throttleMsMilliseconds between published updates. Throttled, not debounced. (optional)
totalFnsCustom aggregations, addressable from column.total.
undoDepth(optional)

Factories and registration

NameWhat it does
createChartDraw one of the thirty-seven chart types from a grid’s own data. It follows the grid’s filters, and a mark can filter the grid back.
chartRangeChart a selected cell range — the spreadsheet gesture. Derives the chart from the range’s shape (a leading text column is the categories, the numeric columns the measures), respects hidden and unreadable columns, and returns the live chart or null when there is nothing to measure.
canChartRangeWhether chartRange would draw something for the grid’s current selection — the question a menu asks before offering the item.
deriveRangeSpecDecide what a chart of a range should be without drawing it: the type, the category column, the measures, and a spec ready for createChart.
regressionPlotsTurn a fitted regression model into diagnostic chart specs ready for createChart: the fit line with its confidence band, residuals-vs-fitted, a QQ plot of the residuals, and a multicollinearity correlogram with the model’s VIF. The plots that need a per-row or per-coefficient quantity the grid has no column for (scale-location, residuals-vs-leverage, the coefficient forest) are returned as a null spec carrying the reason rather than dropped.
createDataRouterSplit one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed rows.apply path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. The router never opens a connection itself — it takes rows, not a URL or a socket — so the host owns the connection (a WebSocket, SSE, CDC, long-poll, anything) and this module owns everything once a message has arrived; see a live WebSocket feed for the worked, runnable example. By default routing is first-match-wins (overlap: false); to fan one partition value to several viewers at once (a grid and a KPI panel and a chart off one feed) create the router with overlap: true — with the default, a second viewer on the same value receives nothing and the router emits a one-time dev warning naming the clash. v2 adds cross-grid selection filtering: link(source, target, relation) makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: subscribe(value, handler) routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; alert(value, condition, handler) evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and configure(spec) (or createDataRouter({ config })) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. v3 also adds per-route reshaping — transform/filter/sort and rollup ({ groupBy, aggregate }) summaries — a relationship graph (relate(edges): multi-hop, several-into-one AND, and mutual edges) that scales v2's pairwise link, and stream hygiene: a seq/version orders and de-duplicates a feed (stale/duplicate deltas dropped, counted in dropped), push with a batch/coalesce buffers a high-frequency feed (flushStream for a deterministic point), and lastSeq/checkpoint/seenThrough let the host resume precisely after its own connection drops and reconnects. v4 adds time-travel: buffer({ window, max }) records the ordered stream into a bounded ring over a moving base, so scrubTo reconstructs a past point, replay (with pause/resume) walks a range, and live returns to the head — every state pushed by the same keyed diff, traveling/buffered reporting the state. v6 adds cross-tab sync: broadcast({ channel }) mirrors the ordered, de-duplicated deltas to other tabs/windows over a BroadcastChannel with no echo loop, a popped-out grid joining the same feed with no second socket and resyncing mid-stream via the reconnect path. v7 adds query-slice routing: query(adapter, request) sources the router from a DFQL/DuckDB (or any pushdown) adapter, partitioning one result across the routes; a route-level where is pushed down where the adapter's capabilities allow and the residual finished client-side, with lastQueryPlan reporting the split. v8 adds write-back: a writable route captures the grid's committed edits off its public edit surface and routes them to onWrite(change, ctx) (per-route or router-global), reverting on reject, re-entering an accepted write as a delta, and surfacing a last-write-wins onConflict; a derived route cannot be writable. v9 adds fan-in: addSource(feed, { map, key }) returns a per-feed handle (load/apply/push/remove) whose rows are normalized and namespaced so many feeds merge into one keyed store without id collisions, removeSource dropping exactly a feed's rows and sources listing them. v10 adds observability: metrics() is a cheap snapshot of per-route/per-source counts and throughput plus the global unrouted/dropped/buffered/lag figures, on('metrics') drives a periodic emit (off unless a listener is registered), and mountDevtools(el) renders a live panel from the module's own DOM file. Detaches its grids on destroy; the host owns them.
createKanbanA board (kanban) view of rows as cards, grouped into columns by a configurable property (a status, a stage, a state field), with per-column card count and an optional points sum, a WIP over-limit flag, configured columns shown even when empty, granular readonly (whole board / per column / per card), field-mapped card templates that reuse the grid’s own column formatters when a grid is bound, and the core pointer events (card:click, card:dblclick, card:contextmenu). It is a dataset viewer like any other: it consumes data through the same keyed-diff rows.apply({ add, update, remove }) contract a grid exposes, so a Data Router can attach(value, board) and drive a kanban beside a grid and a chart off one feed. Accessibility is built in from the start — a labelled group of labelled column lists, cards in a roving-tabindex focus ring with arrow-key navigation, and a polite live region. Every structural property is named in config (columnProperty, pointsProperty, orderProperty, swimlaneProperty, sprintProperty, epicProperty) so it maps DemandFlow and any customer schema without code change. Pass null as the element for a headless board that computes the same model without a DOM. Cards drag between columns (writing the column property) and within a column into a position (writing the order property with fractional ranking), and the same move is keyboard-accessible — Space to grab, arrows to choose a target, Space/Enter to drop, Escape to cancel — announced on the live region; multi-select drags every selected card. A move calls onBeforeMove(card, from, to, index) first (return false to veto) and then persists through the grid’s shipped write-back path — grid-bound via grid.edit.setCells (the same public edit-commit path inline editing uses, so the grid’s pipeline owns optimistic apply / confirm / revert), standalone with a revert when onCardMove rejects — emitting card:move. A configurable per-card contextMenu replaces the card:contextmenu event when present. With swimlanes: true it renders a 2D lane×column grid grouped by swimlaneProperty (per-lane count/points, columns aligned across lanes, a cross-lane drag writing the swimlane property); columns and lanes collapse (state surviving a keyed-diff update), columns reorder by header drag, and setQuickFilter/setFilter/facets drive search and faceting. setSprint/showBacklog/sprints give a sprint view, switcher and backlog; setEpic/epicRollup/rollup give an epic view and rollups (count, points, progress toward the done columns). A card can pop out a nested grid of its children (an epic's stories, a story's tasks, recursively) via a childrenProperty and/or loadChildren(card): the child is a full composed createGrid (or, with asBoard, a nested board) opened in a drawer/modal/inline container — reuse by composition, no grid-core coupling — emitting card:expand/card:drill. Live updates arrive through the same keyed-diff contract a grid uses, so a Data Router drives the board directly (attach(board, predicate)); on a standalone board (not grid-bound, which ignores rows.apply and routes to the bound grid instead) rows.apply({ update }) merges each patch into the row already stored under its key, matching the grid's own rows.apply({ update }) — a field the patch omits is preserved, and a patch that does change the grouping property still moves the card — while add sets the row outright; a live rows.apply re-renders preserving scroll, focus, selection, collapse and any open pop-out. virtualize renders only a scroll window of a tall column; getState/setState (and config.state) save and restore collapse, order, filter and sprint/epic selection; setLoading/setError give loading and error states. The move is fully keyboard-driven — Space to grab, arrows for column/position, Alt+Up/Down across swimlanes, Space/Enter to drop, Escape to cancel — announced on a live region. A field opted in with card: { title: { field, edit: true } } edits inline (double-click or editCard): grid-bound through the grid's own field editor via its public edit path, standalone through a host editor factory or a default input with an onCardEdit revert; a per-column add-card (config.addCard/onAddCard, or grid.edit.addRow) creates a card and opens it in edit. The module imports nothing from the grid's DOM package.
createKPIA KPI / stat-tile view (module kpi) of a dataset as a panel of aggregate tiles — each tile a sum, avg, min, max, count, countDistinct or a custom reducer over the routed rows, with an optional filter predicate, number format (number/currency/percent/compact), a baseline for a delta, semantic threshold bands (thresholds with two cut points and a direction, or an explicit bands list, giving a good/warn/critical status kept separate from any accent), and an optional sparkline series. It is a dataset viewer like any other: it consumes data through the same keyed-diff rows.apply({ add, update, remove }) contract a grid exposes, so a Data Router can attach(value, kpi) and drive a KPI panel beside a grid, a kanban and a chart off one feed. Updates are incremental — a delta adjusts each tile's running accumulator by only the rows it carries (an add contributes, a remove reverses, an update reverses-then-contributes), the two bounded exceptions being a min/max whose current extreme is removed (a rescan of that tile's own value multiset) and a custom reducer (recomputed over the filtered store, an arbitrary function having no inverse). Each tile is a labelled <figure>, focusable and keyboard-activatable, its value announced, the sparkline respecting prefers-reduced-motion; a tile:click event (also from the keyboard) lets a host drill down or filter a routed grid. Pass null as the element for a headless panel that computes the same tile model without a DOM. Not a dashboard layout engine (that is the parked dashboard generator) and no charting beyond the minimal sparkline (that is the charts module).
createAICreate an AI narrative / insights controller (module ai) over a live grid. It produces a short, plain-language narrative of the grid's computed figures — a per-KPI / per-chart / per-column explain, or an insights panel over the current filtered view. The grid makes no AI call of its own: createAI imports no provider SDK, holds no key, and makes no network request; it calls one host callback, ask({ system, messages, prompt, tools?, schema?, signal }) — your model, your key, your privacy decision — the same philosophy as the data adapters. Two grounding paths feed one guard: where the provider offers tool-use the model is given a curated read-only tool set (getSchema, getProfile, getStatistics, getForecast, runQuery) and the grid's own engine computes what it asks for; otherwise the module builds a facts packet from grid.statistics/the profile/forecasts/view counts and passes it in the prompt. Every figure in the narrative is reconciled against the values the engine produced this render — an ungrounded number is stripped before display (the number-reconciliation guard), so a hallucinated figure never reaches the user. The prompt is constrained to narrate-only; the layer is read-only and never mutates data. redact (a column id, a list, or a predicate) and maxRows bound what the module hands ask(), and the module sends nothing itself; an ask() error surfaces a friendly message and the grid stays fully usable, AI being additive rather than load-bearing. Complementary to grid.ai (the intent/plan skill layer): pass no ask and it adopts the grid's configured ai.ask, running the facts-packet path over it. Pass a headless grid for a DOM-free narrative; facts(target) returns the exact grounded packet without calling ask(). UMD global LatticeGridAI.
createTabsCreate a tabbed grid (module tabs): a role="tablist" strip above a stack of role="tabpanel" regions, each hosting its own, independently-configured createGrid instance — "configure each tab as per a normal grid" rather than one grid whose state is swapped (ColumnModel#applyState only repositions/hides/resizes existing columns by id; it carries no field, type or row data, so a state-swap only works when every tab shares one schema). createGrid is injected (createTabs(el, { createGrid, tabs })), the same pattern the React/Vue/Svelte adapters use, so the module imports no engine code and adds nothing to a page that does not load it. A tab that names from: '<tabId>' gets a source: { mode: 'derived', from: <the parent tab’s live grid>, where, group, join, … } wired for it automatically — reusing the shipped derived-source mechanism rather than a new config-inheritance one — and activating a derived tab materialises its whole ancestor chain first; a cyclic from graph is refused (naming the exact cycle) when createTabs is called, not at first click. A tab’s grid mounts on first activation and then stays alive, hidden, so its scroll/selection/filters/sort/grouping/expansion — and an open cell/row editor, left exactly as it was, uncommitted and undiscarded — survive a switch natively; destroy() tears every mounted tab down. The strip is a real tablist with aria-selected, a roving tabindex, and manual-activation keyboard handling (arrows/Home/End move focus, Enter/Space or a click activates). Events: tab:changed, a cancellable beforeTabChange paired with tabChange:cancelled. UMD global LatticeGridTabs.
createLayoutCreate a reconfigurable dashboard layout (module layout): a cell grid inside an element, and a set of windows on it that a user can move, resize and close by drag or by keyboard — the surface a customer would otherwise reach for GridStack to get. It is payload-agnostic: a window body is a div with an id that the module creates, sizes and never reads, so it imports no engine code at all (not even createGrid) and the whole module is about 17,500 bytes gzipped on the built bundle, of which roughly 2KB is the shared module runtime. columns/rows divide the element; overflowX and overflowY are independent axes, each 'static' (tracks divide the container with minmax(0, 1fr)) or 'scroll' (tracks take a fixed columnWidth/rowHeight and the canvas extends past the viewport, so a column keeps the size it asked for — measured: shrinking a 600px host to 300px leaves a 200px column at 200px). Spacing takes a real CSS length: a number of pixels, or '200px', '25%', '1fr', '2rem'; anything else is refused by name and replaced by the default, because the value reaches an inline style. Windows are placed by 1-based xPos/yPos/xSize/ySize, or auto-placed in the first free cell; chrome defaults on, and closable/movable/resizable all default off, so a fixed dashboard is fixed without opting out. compact: 'vertical' pushes displaced windows down then pulls them up (window:moved carries both to and landed); 'none' keeps every window where it is put. closable/movable/resizable each also take a layout-level default of the same name, which a window's own boolean overrides, and setInteractive(true|false|{movable, resizable, closable}) changes that default at runtime — the “Edit layout” button — without destroying the layout or any payload in it, with getInteractive() reading it back. Locking always wins and unlocking never overrides an opt-out: setInteractive(false) pins a window that declared movable: true, while setInteractive(true) leaves a window that declared movable: false pinned. The config key and the method are deliberately different: movable: false in the config states the default for windows that declare nothing and takes nothing away from one that opted in, whereas setInteractive(false) is an active lock. getInteractive() is three-valued — undefined for unset, true, or false for a lock — and a key carrying undefined is treated as absent, so setInteractive(getInteractive()) is a no-op in every state. It moves both halves of the enforcement, the rendered handles and the pointer and keyboard gesture checks, and fires no event because a mode is not an arrangement — getLayout() neither carries it nor restores it. A locked layout is not a read-only dashboard: what is inside a window is configured with that payload's own settings. maximise(id), minimise(id) and restore(id) are the display modes, with maximised() and minimised() reading them back: maximise fills the layout host rather than the browser window (no position: fixed, whose containing block is the nearest ancestor with a transform or a contain; no reparenting; nothing that can disturb the page around the dashboard), hides the other windows, runs no compaction at all and keeps the payload container as the very same DOM node — and Escape restores it from anywhere inside the layout. minimise draws a window as a single row and hides its payload while its chrome stays to carry the way back, so the windows below pull up on screen; in the arrangement nothing moves at all, because the collapse is a projection of the dashboard rather than a change to it, so restoring gives back exactly the arrangement that was there in any order and with any number of other windows still collapsed. A chrome: false window is refused by name. Both controls are opt-in per window (maximisable, minimisable) with a layout-level default of the same name, and setInteractive() deliberately does not touch either: a mode is not an arrangement, so neither appears in getLayout(), which reports the underlying placement in both states. Keyboard parity with the drag: a focusable handle per window running the kanban board's grab/move/drop/cancel model, with a polite live region announcing grabbed, every tentative position, dropped, cancelled and reverted. It owns exactly one ResizeObserver for the whole layout, over two targets, and tells payloads their new content box through window:resized — it never calls into a payload, because it cannot know what one is. Events: window:moved, window:resized, window:closed, layout:changed, the cancellable beforeWindowMove/beforeWindowResize/beforeWindowClose and their *:cancelled pairs; drag progress is not emitted per frame. getLayout()/setLayout() round-trip the arrangement as plain JSON, and getState()/setState() are the versioned pair. Closing a window does not destroy its payload — the container is handed back on window:closed and the host owns that lifecycle. UMD global LatticeGridLayout.
createDevtoolsMount the devtools panel against a grid, including its accessibility checks.
createGanttCreate a project-planning Gantt controller (module gantt) over a task list and a dependency list. A CPM engine (computeSchedule) computes each task's early/late start and finish, its slack and the zero-float critical path, honouring the four link types (LINK_TYPES: FS/SS/FF/SF) with lag, and recomputes on every edit — emitting schedule or, on a dependency cycle or bad input, error (a code from SCHEDULE_ERROR). Milestones are zero-duration points; summary (WBS) tasks are derived from their children (earliest start, latest finish, weighted progress) rather than scheduled; findViolations flags a task placed earlier than its predecessors allow, and toISODate maps an engine day-number back to a calendar date.
createLatticeGridElementBuild the element class without registering it, for a custom registry.
createMessagesBuild a message catalogue. A partial set lays over the built-in British English one.
defineLatticeGridRegister &lt;lattice-grid&gt;, or your own tag name.
defineUnitAdd one unit to a system, or override one of ours under the same name.
registerChartTypeRegister an extension chart type so createChart({ type }) can draw it (BACKLOG-0000886). Extension types ship as their own opt-in modules (e.g. modules/chart-ridgeline), so the base charts bundle does not grow for a type a caller never imports — you pay only for the charts you use. registeredChartTypes() lists what is registered.
registerModulesInstall optional modules once, for every grid on the page.
registerSchemeAdd a colour scheme, or replace one of ours under the same name.
restoreStateWithinPut it back afterwards.

Grid methods

NameWhat it does
attachRendererBind a renderer to a headless grid.
emitEmit on the grid's bus, for custom components.
getPinnedRowsThe objects pinned at one edge, as a copy.
rendererHostThe host object a renderer reads: columns, rows, callbacks. Deliberately a plain bag rather than the grid itself, so a renderer cannot reach into core internals. You need this only when writing a renderer of your own.
setAllWrite several in one pass. Emits one config:changed for the batch, not one per key.

Module configuration

The optional module bundles take their own configuration object rather than extending GridConfig. Each is documented in full in the reference; these are the keys most easily missed, listed here so that reading the guide end to end leaves nothing you have never heard of. A callback named on… is a second route to an event the module already raises: passing the callback in config and binding the event both work, and both fire.

NameWhat it does
onBeforeTabChangeTabs (TabsConfig). Cancellable gate before the active tab changes: return false, or call preventDefault() on the event, to veto the switch — an unsaved edit, say. A veto fires onTabChangeCancelled instead of the change. May return a promise, in which case the switch waits on it.
onTabChangeTabs (TabsConfig). Fires after the active tab has changed. The config route to the tab:changed event.
onTabChangeCancelledTabs (TabsConfig). Fires when onBeforeTabChange vetoed a switch, carrying the resolved reason. The config route to the tabChange:cancelled event.
onBeforeWindowCloseLayout (LayoutConfig). Cancellable gate before a window closes: return false to veto, which fires onWindowCloseCancelled instead. May return a promise.
onBeforeWindowMoveLayout (LayoutConfig). Cancellable gate before a window moves: return false to veto, which fires onWindowMoveCancelled instead. May return a promise.
onBeforeWindowResizeLayout (LayoutConfig). Cancellable gate before a window resizes: return false to veto, which fires onWindowResizeCancelled instead. May return a promise.
onLayoutChangedLayout (LayoutConfig). Fires after a layout change with the full layout snapshot — the shape you persist and restore. The config route to the layout:changed event.
onWindowCloseCancelledLayout (LayoutConfig). Fires when onBeforeWindowClose vetoed a close. The config route to the windowClose:cancelled event.
onWindowClosedLayout (LayoutConfig). Fires after a window has closed. The config route to the window:closed event.
onWindowMoveCancelledLayout (LayoutConfig). Fires when onBeforeWindowMove vetoed a move. The config route to the windowMove:cancelled event.
onWindowMovedLayout (LayoutConfig). Fires after a window has moved. The config route to the window:moved event.
onWindowResizeCancelledLayout (LayoutConfig). Fires when onBeforeWindowResize vetoed a resize. The config route to the windowResize:cancelled event.
onWindowResizedLayout (LayoutConfig). Fires after a window has resized. The config route to the window:resized event.
nullTextKPI (KPIConfig). The placeholder rendered in place of a null tile value. Defaults to an em dash.
onChangeKPI (KPIConfig). Fires after every update with the resolved model: its tiles, and its nodes when the strip is a tree. The config route to the change event.
onNodeToggleKPI (KPIConfig). Fires when a branch of a KPI tree opens or closes, with the node key and its new expanded state. The config route to the node:toggle event.
onTileClickKPI (KPIConfig). Tile click handler, for drilling into what a tile summarises. The config route to the tile:click event.
onTileContextMenuKPI (KPIConfig). Tile right-click handler. The config route to the tile:contextmenu event.
onTileDblClickKPI (KPIConfig). Tile double-click handler. The config route to the tile:dblclick event.
ariaLabelKanban (KanbanConfig). The board's accessible name. Defaults to Board.
columnOrderKanban (KanbanConfig). An explicit column order, by column id.
doneColumnsKanban (KanbanConfig). Column ids that count as done when a rollup computes progress. A column definition's done: true says the same thing.
emptyTextKanban (KanbanConfig). Host-localised placeholder shown in a column holding no cards. Empty by default, which shows no placeholder at all.
enforceWipKanban (KanbanConfig). Enforce wipLimit as a hard gate: a move that would take a column over its limit is refused rather than merely flagged. Default false.
laneOrderKanban (KanbanConfig). An explicit swimlane order, by lane id. Also written by a lane-header drag.
onCardClickKanban (KanbanConfig). Card click handler. The config route to the card:click event.
onCardContextMenuKanban (KanbanConfig). Card right-click handler. The config route to the card:contextmenu event.
onCardDblClickKanban (KanbanConfig). Card double-click handler. The config route to the card:dblclick event.
quickFilterKanban (KanbanConfig). Quick-filter text, matched case-insensitively across a card's fields.
showPointsKanban (KanbanConfig). Show the points sum in each column header. Needs pointsProperty to say which row property carries the points.
createdPropertyKanban SLA (KanbanSlaConfig). The row property holding the wall-clock time the card was created.
enteredPropertyKanban SLA (KanbanSlaConfig). The row property holding the wall-clock time the card entered its current column.
ignoreDoneKanban SLA (KanbanSlaConfig). Whether cards in a done column are exempt from ageing. Default true.
onBreachKanban SLA (KanbanSlaConfig). Called on a rising crossing into breach level, as (level, rows) — the same shape the router's alert handler takes.
onWarnKanban SLA (KanbanSlaConfig). Called on a rising crossing into warn level, as (level, rows) — the same shape the router's alert handler takes.
showAgeKanban SLA (KanbanSlaConfig). Show the age chip on every aged card ('always'), or only once a card reaches warn or breach ('threshold', the default).
useTransitionLogKanban SLA (KanbanSlaConfig). Whether the flow transition log drives the ageing basis when one is present. Default true.
autoApplyAI (AIConfig). Ask-your-data: apply a safe, read-only query result without a confirm step. Off by default — the resolved query is shown and waits for Apply.
onErrorAI (AIConfig). Called when ask() errors, with the error and the target it was asked of. The grid stays usable.
onNarrativeAI (AIConfig). Called when a narrative has been produced.
onProposalAI (AIConfig). Called with each governed-actor proposal, before any approval.
onQueryAI (AIConfig). Called with each ask-your-data result.
schemaOptionsAI (AIConfig). Budgets passed to the schema builder that describes your data to the model for ask-your-data.

Kanban config keys, executed

The table above names each Kanban config key on its own line; these six examples run the board (headless, or over the test DOM where a key's effect is visual) and show what each key actually does, grouped by what they configure together.

Board structure: grouping, order and a done set, executed

const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
  { id: 1, status: 'todo', epic: 'E1', points: 3, order: 20 },
  { id: 2, status: 'todo', epic: 'E1', points: 2, order: 10 },
  { id: 3, status: 'done', epic: 'E1', points: 5, order: 5 },
];
const b = createKanban(null, {
  rows, rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'done' }],
  columnOrder: ['done', 'todo'],
  pointsProperty: 'points', showPoints: true,
  orderProperty: 'order',
  doneColumns: ['done'],
});
const ids = b.columns().map((c) => c.id);
const todoOrder = b.column('todo').cards.map((c) => c.row.id);
const donePoints = b.rollup('epic')[0].donePoints;
return (`${ids.join(',')} | ${todoOrder.join(',')} | points ${b.points('todo')} | done ${donePoints}`);

columnOrder pins done before todo though it was configured second; orderProperty sorts card 2 (order 10) before card 1 (order 20) inside todo; pointsProperty is what makes b.points('todo') a real sum rather than 0 (showPoints only decides whether that sum is drawn into the DOM column header — see the accessible-name example below for that half); and doneColumns: ['done'] — set directly, with no column def carrying done: true — is what lets rollup('epic') count card 3's points as done.

Sprint, epic and swimlane selection, executed

const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
  { id: 1, status: 'todo', sprint: 'S1', epic: 'E1', assignee: 'Ann' },
  { id: 2, status: 'todo', sprint: 'S2', epic: 'E1', assignee: 'Bob' },
  { id: 3, status: 'todo', sprint: 'S1', epic: 'E2', assignee: 'Cy' },
];
const b = createKanban(null, {
  rows, rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }],
  sprintProperty: 'sprint', sprint: 'S1',
  epicProperty: 'epic', epic: 'E1',
  swimlaneProperty: 'assignee', swimlanes: true,
  lanes: [{ id: 'Ann', title: 'Ann T' }, { id: 'Zed', title: 'Empty lane' }],
  laneOrder: ['Zed', 'Ann'],
  sprints: [{ id: 'S1', title: 'Sprint 1' }, { id: 'S2', title: 'Sprint 2' }],
});
const visible = [...b.model.cardsByKey.keys()];
const laneIds = b.model.lanes.map((l) => l.id);
const emptyLane = b.model.lanes.find((l) => l.id === 'Zed');
return (`${visible.join(',')} | lanes ${laneIds.join(',')} | empty ${emptyLane ? emptyLane.count : 'MISSING'} | sprints ${b.sprints().join(',')}`);

The board is constructed already narrowed to sprint S1 and epic E1 — no setSprint/ setEpic call — so only card 1 is visible. lanes configures an Ann lane and a Zed lane nobody's assignee matches, kept (count 0) because it is configured, exactly as an empty column is; laneOrder pins it first. sprints lists both configured sprints even though only S1 has a visible card.

Card mapping, quick filter and readonly, executed

const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
  { id: 1, status: 'todo', title: 'Login form' },
  { id: 2, status: 'todo', title: 'OAuth flow' },
];
const b = createKanban(null, {
  rows, rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }],
  card: { title: 'title' },
  quickFilter: 'oauth',
  readonly: { columns: { todo: true } },
});
const visibleTitles = [...b.model.cardsByKey.values()].map((c) => c.fields.title);
const isReadonly = b.readonly({ column: 'todo' });
return (`${visibleTitles.join(',')} | readonly ${isReadonly}`);

card: { title: 'title' } is what makes c.fields.title readable at all; the initial quickFilter narrows the board to the one matching card before anything renders; and the per-column readonly map reports todo as locked.

Accessible name, localised labels and selection, executed

const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createKanban } = await import('../packages/modules/kanban/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
dom.root.appendChild(el);
const b = createKanban(el, {
  rows: [{ id: 1, status: 'todo', title: 'A', points: 5 }],
  rowKey: 'id', columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'blocked' }],
  card: { title: 'title' },
  ariaLabel: 'Sprint board',
  emptyText: 'No cards',
  selectable: false,
  labels: { grabbed: 'Grabbed' },
  pointsProperty: 'points', showPoints: true,
});
const cardEl = el.querySelector('.lat-kanban__card');
cardEl.dispatchEvent(new TestEvent('click', {}));
const selectedAfterClick = b.selection().length;
cardEl.setAttribute('tabindex', '0');
cardEl.dispatchEvent(new TestEvent('keydown', { key: ' ' }));
const liveText = b.live.textContent;
const blockedEmpty = [...el.querySelectorAll('.lat-kanban__column')].find(c => c.dataset.column === 'blocked').querySelector('.lat-kanban__empty').textContent;
const ariaLabel = el.getAttribute('aria-label');
const todoContent = el.querySelector('.lat-kanban__headcontent');
const hasPoints = /lat-kanban__points/.test(todoContent.innerHTML);
return (`${ariaLabel} | selectable-click ${selectedAfterClick} | grab '${liveText}' | empty '${blockedEmpty}' | points ${hasPoints}`);

ariaLabel replaces the default "Board" name; selectable: false means a plain click leaves the selection empty (the keyboard grab is a separate mechanism, unaffected); labels.grabbed supplies the word the live region announces; emptyText is the placeholder shown in the empty blocked column; and showPoints (with pointsProperty) is what draws the points span into the column header — its real job, distinct from pointsProperty alone computing the sum (see the board structure example above, where showPoints is absent and b.points() still works).

Add-card, edit and move lifecycle, plus WIP enforcement, executed

const { createKanban } = await import('../packages/modules/kanban/index.js');
const seen = [];
const b = createKanban(null, {
  rows: [{ id: 'a', status: 'todo' }, { id: 'b', status: 'doing' }],
  rowKey: 'id', columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'doing', wipLimit: 1 }, { id: 'blocked' }, { id: 'review' }],
  card: { title: { field: 'title', edit: true } },
  addCard: true,
  onAddCard: (columnId) => ({ id: 'new', status: columnId, title: 'Created by onAddCard' }),
  onCardEdit: () => true,
  onBeforeMove: (card, from, to) => to !== 'blocked',
  onCardMove: (e) => e.keys[0] !== 'a' || e.to !== 'review',
  onCardClick: (e) => seen.push(`click:${e.card.key}`),
  onCardDblClick: (e) => seen.push(`dbl:${e.card.key}`),
  onCardContextMenu: (e) => seen.push(`ctx:${e.card.key}`),
  enforceWip: true,
});

const newKey = b.addCard('todo');
const createdTitle = b.card(newKey).row.title;
await b.applyEdit('a', 'title', 'Renamed');
const editedTitle = b.card('a').row.title;

const wipRefused = await b.move('a', 'doing');
const vetoRefused = await b.move('a', 'blocked');
const revertedMove = await b.move('a', 'review');

b.fire('card:click', { card: b.card('a') });
b.fire('card:dblclick', { card: b.card('a') });
b.fire('card:contextmenu', { card: b.card('a') });

return ([
  `created ${createdTitle}`,
  `edited ${editedTitle}`,
  `wip-moved ${wipRefused.moved.length}`,
  `veto-moved ${vetoRefused.moved.length}`,
  `reverted ${revertedMove.reverted}`,
  `events ${seen.join(',')}`,
].join(' | '));

addCard opts the column into add-card at all; onAddCard supplies the new row rather than an auto-generated one; onCardEdit confirms the inline edit. enforceWip refuses the move into doing (already at its limit of 1) before onBeforeMove is ever asked; a separate move into blocked is vetoed by onBeforeMove instead; and a move into review — allowed by both — is still reverted because onCardMove rejects it. onCardClick/ onCardDblClick/onCardContextMenu each fire from the matching card:* event.

Card aging / SLA: basis, thresholds, callbacks and the tick, executed

const { createKanban } = await import('../packages/modules/kanban/index.js');
const E = 1_700_000_000_000;
const DAY = 24 * 60 * 60 * 1000;

// basis comparison: same row data, two boards differing only in `basis`.
const rowData = { id: 'x', status: 'doing', enteredAt: E - 1 * DAY, createdAt: E - 10 * DAY };
function makeBoard(basis) {
  return createKanban(null, {
    rows: [rowData],
    rowKey: 'id', columnProperty: 'status',
    columns: [{ id: 'doing' }],
    flow: false,
    sla: {
      basis, enteredProperty: 'enteredAt', createdProperty: 'createdAt',
      useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 },
      now: () => E,
    },
  });
}
const byColumn = makeBoard('column').sla.stateFor('x').level; // uses enteredAt (1d) -> ok
const byBoard = makeBoard('board').sla.stateFor('x').level;   // uses createdAt (10d) -> breach

// ignoreDone
const doneRow = { id: 'd', status: 'done', enteredAt: E - 30 * DAY };
const withIgnore = createKanban(null, {
  rows: [doneRow], rowKey: 'id', columnProperty: 'status',
  columns: [{ id: 'done', done: true }], flow: false,
  sla: { enteredProperty: 'enteredAt', useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 }, now: () => E },
});
const ignoredByDefault = withIgnore.sla.stateFor('d').level;
const notIgnored = createKanban(null, {
  rows: [doneRow], rowKey: 'id', columnProperty: 'status',
  columns: [{ id: 'done', done: true }], flow: false,
  sla: { enteredProperty: 'enteredAt', useTransitionLog: false, ignoreDone: false, warn: { days: 2 }, breach: { days: 5 }, now: () => E },
}).sla.stateFor('d').level;

// onWarn/onBreach + showAge
const hits = [];
const warnBoard = createKanban(null, {
  rows: [
    { id: 'w', status: 'doing', enteredAt: E - 3 * DAY },
    { id: 'br', status: 'doing', enteredAt: E - 6 * DAY },
  ],
  rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
  sla: {
    enteredProperty: 'enteredAt', useTransitionLog: false,
    warn: { days: 2 }, breach: { days: 5 }, now: () => E, showAge: 'always',
    onWarn: (level, rows) => hits.push(`warn:${level}:${rows[0].id}`),
    onBreach: (level, rows) => hits.push(`breach:${level}:${rows[0].id}`),
  },
});
const showAgeState = warnBoard.sla.stateFor('w').ageText;

// tick: a real re-check interval that fires without a manual evaluate()/move.
const clock = { t: E };
const ticks = [];
const tickBoard = createKanban(null, {
  rows: [{ id: 't', status: 'doing', enteredAt: E }],
  rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
  sla: {
    enteredProperty: 'enteredAt', useTransitionLog: false,
    breach: { days: 1 }, tick: 15, now: () => clock.t,
  },
});
tickBoard.on('card:sla', (e) => ticks.push(e.level));
clock.t = E + 2 * DAY; // now past breach, but nobody called evaluate()
await new Promise((resolve) => setTimeout(resolve, 60));
tickBoard.sla.destroy();

return ([
  `basis-column ${byColumn}`,
  `basis-board ${byBoard}`,
  `ignore-default ${ignoredByDefault}`,
  `ignore-false ${notIgnored}`,
  `hits ${hits.join(',')}`,
  `age ${showAgeState}`,
  `tick-fired ${ticks.length > 0}`,
].join(' | '));

The same card (an enteredAt of 1 day and a createdAt of 10 days) is ok under basis: 'column' (which prefers enteredProperty) and breach under basis: 'board' (which prefers createdProperty) — the same warn/breach thresholds, only the basis changed. ignoreDone defaults a done card to no ageing at all (null) and ignoreDone: false ages it anyway. onWarn and onBreach each fire, shaped (level, rows), for the card that reaches that level; showAge: 'always' gives an age chip on a card that is not yet warned. tick re-evaluates on its own timer — the card crosses into breach with no move or manual evaluate() call — and useTransitionLog: false throughout is what keeps every reading on the row timestamps rather than the flow log.

Custom card body, virtualization and a child pop-out, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createKanban } = await import('../packages/modules/kanban/index.js');

const dom = createTestDom({});
const el = dom.document.createElement('div');
dom.root.appendChild(el);

const many = [];
for (let i = 0; i < 200; i++) many.push({ id: i, status: 'todo', title: `C${i}`, parent: null });
many.push({ id: 'e1', status: 'todo', title: 'Epic', parent: null });
many.push({ id: 's1', status: 'todo', title: 'Story', parent: 'e1' });

const b = createKanban(el, {
  rows: many, rowKey: 'id', columnProperty: 'status', columns: [{ id: 'todo' }],
  card: { title: 'title' },
  cardRenderer: (card) => `<div class="custom">${card.fields.title}!</div>`,
  virtualize: { rowHeight: 100, viewport: 500, threshold: 50, overscan: 2 },
  children: { property: 'parent' },
});

const list = el.querySelector('.lat-kanban__cards');
const rendered = list.querySelectorAll('.lat-kanban__card').length;
const isVirtual = list.dataset.virtual;
const first = list.querySelector('.lat-kanban__card');
const customRendered = /class="custom"/.test(first.innerHTML);
const canExpandEpic = b.canExpand(b.card('e1'));
const canExpandLeaf = b.canExpand(b.card('s1'));

return ([
  `virtual ${isVirtual}`,
  `rendered ${rendered > 0 && rendered < 202}`,
  `custom ${customRendered}`,
  `expand-epic ${canExpandEpic}`,
  `expand-leaf ${canExpandLeaf}`,
].join(' | '));

202 cards in one column, but virtualize renders only a scroll window of them; cardRenderer owns the whole card body, replacing the default template; and children: { property: 'parent' } is what makes the epic card expandable (canExpand true) while a plain story is not.

Layout config keys, executed

The reference table names each Layout config key on its own line; these five examples run a real dashboard over the test DOM and show what each key actually does, grouped by what they configure together.

Geometry: overflow axes, track size, gap and padding, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
  columns: 6, rows: 4, overflowX: 'scroll', overflowY: 'static', columnWidth: '200px',
  gap: '20px', padding: '15px', compact: 'none',
  windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 2, ySize: 1 }],
});
const canvas = host.querySelector('.lat-layout__canvas');
const viewport = host.querySelector('.lat-layout__viewport');
return (`${viewport.style.overflowX} ${viewport.style.overflowY} | ${canvas.style.gridTemplateColumns} | gap ${canvas.style.gap} | padding ${layout.payload('a').style.padding}`);

overflowX: 'scroll' is the axis that scrolls; overflowY stays 'static' independently; columnWidth is the fixed track a scrolling axis repeats; and gap/padding reach the DOM as the real CSS lengths configured, not their 8px/5px built-in fallbacks. compact: 'none' is what a single-window layout cannot show on its own — see the before-events example below for compact's effect on a move.

Layout-level interactivity defaults, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
  columns: 3, rows: 1,
  closable: true, movable: true, resizable: true, maximisable: true, minimisable: true,
  windows: [
    { id: 'bare', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
    { id: 'opted-out', xPos: 2, yPos: 1, xSize: 1, ySize: 1, movable: false },
  ],
});
const frame = (id) => host.querySelector(`[data-window-id="${id}"]`);
const bareHasClose = !!frame('bare').querySelector('.lat-layout__close');
const bareHasGrip = !!frame('bare').querySelector('.lat-layout__grip');
const optedOutHasGrip = !!frame('opted-out').querySelector('.lat-layout__grip');
const canMaximise = layout.maximise('bare');
const canMinimise = layout.minimise('bare');
return (`close ${bareHasClose} | grip ${bareHasGrip} | opted-out-grip ${optedOutHasGrip} | maximise ${canMaximise} | minimise ${canMinimise}`);

Every layout-level default (closable, movable, resizable, maximisable, minimisable) applies to bare, which declares none of its own; opted-out's own movable: false beats the layout-level true, which is why it renders no grip. maximisable/minimisable are what let maximise()/minimise() succeed on a window that never declared either itself.

The window list and a saved arrangement applied at mount, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
  columns: 4, rows: 4, compact: 'none',
  windows: [
    { id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
    { id: 'b', xPos: 2, yPos: 1, xSize: 1, ySize: 1 },
  ],
  layout: { columns: 4, rows: 4, windows: [{ id: 'a', xPos: 3, yPos: 2, xSize: 1, ySize: 1 }] },
});
const ids = layout.windows();
const snap = layout.getLayout();
const aPlaced = snap.windows.find((w) => w.id === 'a');
const bPlaced = snap.windows.find((w) => w.id === 'b');
return (`${ids.join(',')} | a@${aPlaced.xPos},${aPlaced.yPos} | b@${bPlaced.xPos},${bPlaced.yPos}`);

windows declares two windows, a at column 1 and b at column 2; the mount-time layout arrangement then moves a to (3, 2), overriding its own declared placement, while b — not named in layout — stays exactly where windows put it.

Move and resize before-events, independently gated and paired with their cancellations, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const moved = [];
const resized = [];
const cancelledMove = [];
const cancelledResize = [];
const layout = createLayout(host, {
  columns: 4, rows: 4, compact: 'none',
  windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 }],
  onWindowMoved: (e) => moved.push(e),
  onWindowResized: (e) => resized.push([e.id, e.xSize, e.ySize]),
  onBeforeWindowMove: (e) => (e.to.xPos === 4 ? e.preventDefault('pinned') : true),
  onWindowMoveCancelled: (e) => cancelledMove.push(e.reason),
  onBeforeWindowResize: (e) => (e.to.xSize === 3 ? e.preventDefault('too-wide') : true),
  onWindowResizeCancelled: (e) => cancelledResize.push(e.reason),
});
const okMove = await layout.move('a', { xPos: 2 });
const vetoedMove = await layout.move('a', { xPos: 4 });
const okResize = await layout.move('a', { xSize: 2 });
const vetoedResize = await layout.move('a', { xSize: 3 });
return (`moved ${okMove}/${vetoedMove} to xPos ${layout.getLayout().windows[0].xPos} | resized ${okResize}/${vetoedResize} to xSize ${layout.getLayout().windows[0].xSize} | cancelled-move ${cancelledMove.join(',')} | cancelled-resize ${cancelledResize.join(',')} | moved-events ${moved.length} | resized-events ${resized.length}`);

move(id, to) resolves to a resize or a move from what changed. The first move and the first resize both succeed and fire onWindowMoved/onWindowResized once each; the second of each is vetoed by its own before-hook — onBeforeWindowMove never sees the resize and onBeforeWindowResize never sees the move — firing onWindowMoveCancelled/onWindowResizeCancelled with the veto's reason and leaving the window exactly where it was.

Close before-event and layout:changed, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const closed = [];
const cancelledClose = [];
const changed = [];
const layout = createLayout(host, {
  columns: 4, rows: 4, compact: 'none',
  windows: [
    { id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1, closable: true },
    { id: 'locked', xPos: 2, yPos: 1, xSize: 1, ySize: 1, closable: true },
  ],
  onWindowClosed: (e) => closed.push(e.id),
  onBeforeWindowClose: (e) => (e.id === 'locked' ? e.preventDefault('unsaved') : true),
  onWindowCloseCancelled: (e) => cancelledClose.push(e.reason),
  onLayoutChanged: (e) => changed.push(e.cause),
});
const okClose = await layout.close('a');
const vetoedClose = await layout.close('locked');
return (`closed ${okClose}/${vetoedClose} | ids ${closed.join(',')} | cancelled ${cancelledClose.join(',')} | changed-causes ${changed.join(',')} | remaining ${layout.windows().join(',')}`);

Closing a succeeds, firing onWindowClosed and onLayoutChanged (cause 'close'); closing locked is vetoed by onBeforeWindowClose, firing onWindowCloseCancelled with the reason instead, and it survives in windows().

AI config keys, executed

The reference table names each AI config key on its own line; these three examples run a real controller (a MOCK ask() throughout — never a real provider) and show what each key actually does.

The insights panel: element, tools, enable, maxColumns, reconcile, onNarrative, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'a' }, { field: 'b' }],
  rows: [{ id: 1, a: 1, b: 2 }, { id: 2, a: 3, b: 4 }],
});
const narrated = [];
const ai = createAI(grid, {
  ask: async () => ({ text: 'There are 2 rows. Confidence 900%.' }),
  element: el,
  tools: false,
  maxColumns: 1,
  reconcile: 'flag',
  enable: ['narrative'],
  onNarrative: (r) => narrated.push(r.text),
});
ai.insights();
const result = await ai.explain({ kind: 'view' });
const cappedFacts = ai.facts({ kind: 'view' }).facts.length;
const uncapped = createAI(grid, { ask: async () => ({ text: '' }) }).facts({ kind: 'view' }).facts.length;
return (`${result.text} | narrated ${narrated.length} | flagged ${result.flagged.join(',')} | panel ${!!el.querySelector('.lat-ai__btn')} | facts ${cappedFacts}/${uncapped}`);

element + tools: false (packet mode, no function-calling) are what make ai.insights() mount a real panel button; enable: ['narrative'] is what lets that mounting succeed at all — leaving it out of enable would return with no panel. enable gates only the three DOM-mounting convenience methods (insights(), askBar(), actorBar()): the ai.explain() call right below still runs and returns a result even though 'insights'/'query'/'ask'/'actor' are absent from this enable list — the programmatic API is never gated, so a host that wants no AI surface at all simply never calls these methods. reconcile: 'flag' keeps the ungrounded "900%" in the text (the default strips it instead) while still reporting it in flagged; onNarrative fires with the same result explain() returns; maxColumns: 1 is why the capped run gathers 12 facts against 34 uncapped.

Ask-your-data: autoApply, schemaOptions, redact, router, onQuery, onError, executed

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'region' }, { field: 'ssn' }],
  rows: [
    { id: 1, region: 'EMEA', ssn: 'a' },
    { id: 2, region: 'AMER', ssn: 'b' },
    { id: 3, region: 'EMEA', ssn: 'c' },
  ],
});
const chart = createHeadlessGrid({ rowKey: 'id', columns: [{ field: 'id' }, { field: 'region' }] });
const router = createDataRouter({ rowKey: 'id', overlap: true });
router.attach(chart, () => true);

const errors = [];
const queried = [];
let capturedSchema = null;
const ask = async (req) => {
  capturedSchema = req.schema;
  return { actions: [{ type: 'setFilters', filters: { col: 'region', op: 'eq', value: 'EMEA' } }] };
};
const ai = createAI(grid, {
  ask, router, autoApply: true, schemaOptions: { maxColumns: 2 }, redact: ['ssn'],
  onQuery: (r) => queried.push(r.ok), onError: (e) => errors.push(e),
});
await ai.query('EMEA only');
const chartIds = [];
for (let i = 0; i < chart.rows.count(); i++) chartIds.push(chart.rows.get(i).key);
return (`queried ${queried.join(',')} | schema-cols ${capturedSchema.columns.map((c) => c.id).join(',')} | chart ${chartIds.sort().join(',')} | errors ${errors.length}`);

schemaOptions: { maxColumns: 2 } is why the model is shown only id,regionssn is also stripped by redact, so it never reaches the schema either way; autoApply runs the safe read the moment it resolves, with no confirm step; router, configured once here rather than passed to every call, is what fans the answer to the chart with no opts.router anywhere in this example; onQuery fires with the result, and onError is wired but silent because nothing failed.

The governed actor: onProposal, executed

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name' }, { field: 'score', type: 'number', edit: { enabled: true } }],
  rows: [{ id: 1, name: 'Alpha', score: 10 }],
});
const proposals = [];
const ask = async () => ({ structured: { edits: [{ match: 'Alpha', column: 'score', value: 50 }] } });
const ai = createAI(grid, { ask, onProposal: (r) => proposals.push(r.ok) });
const result = await ai.propose('set Alpha score to 50');
const report = await ai.applyProposal(result);
return (`proposals ${proposals.join(',')} | diff ${result.diff.map((d) => `old ${d.oldValue} new ${d.newValue}`).join(',')} | applied ${report.applied} | score-now ${grid.rows.byKey('1').data.score}`);

onProposal fires with the built diff before any approval; applyProposal is the separate human-approval step that actually writes, through the grid's own gated edit path.

KPI config keys, executed

Three examples covering the eleven KPI config keys not already demonstrated elsewhere in this guide.

The tile hierarchy: tiles, an explicit separator, expanded, onNodeToggle, executed

const { createKPI } = await import('../packages/modules/kpi/index.js');

const HOSTS = [{ id: 1, cpu: 91, mem: 40, lat: 12 }, { id: 2, cpu: 30, mem: 55, lat: 40 }];
const toggled = [];
const kpi = createKPI(null, {
  rows: HOSTS, rowKey: 'id',
  tree: { separator: '/', expanded: true },
  tiles: [
    { id: 'compute/cpu', label: 'cpu', aggregation: 'max', field: 'cpu' },
    { id: 'compute/memory', label: 'memory', aggregation: 'avg', field: 'mem' },
    { id: 'network/latency', label: 'latency', aggregation: 'max', field: 'lat' },
  ],
  onNodeToggle: (e) => toggled.push([e.key, e.expanded]),
});
const topLevel = kpi.nodes().map((n) => n.label).sort();
const key = kpi.nodes()[0].key;
kpi.toggle(key);
return (`${topLevel.join(',')} | tiles ${kpi.model.tiles.length} | toggled ${toggled.map((t) => t.join(':')).join(',')}`);

tiles declares the three measured leaves; separator: '/' is what derives compute/network from ids that only contain /, not the default .; expanded: true opens every branch at construction, so the very first toggle() call closes one rather than opening it; onNodeToggle reports exactly that.

Grid-bound events: fields, onTileClick, onTileDblClick, onTileContextMenu, onChange, executed

const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createKPI } = await import('../packages/modules/kpi/index.js');

const dom = createTestDom({});
const el = dom.document.createElement('div');
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'id', field: 'id' }, { id: 'priority', field: 'priority' }],
  rows: [{ id: 1, priority: 'P1' }, { id: 2, priority: 'P2' }],
});
const clicks = [];
const dbl = [];
const ctx = [];
const changes = [];
const kpi = createKPI(el, {
  grid,
  fields: ['priority'],
  tiles: [{ id: 'p1', label: 'P1 jobs', aggregation: 'count', filter: (r) => r.priority === 'P1' }],
  onTileClick: ({ id }) => clicks.push(id),
  onTileDblClick: ({ id }) => dbl.push(id),
  onTileContextMenu: ({ id }) => ctx.push(id),
  onChange: (e) => changes.push(e.model.tiles[0].value),
});
const fig = el.querySelector('.lat-kpi__tile');
fig.dispatchEvent(new TestEvent('click', {}));
fig.dispatchEvent(new TestEvent('dblclick', {}));
fig.dispatchEvent(new TestEvent('contextmenu', {}));
return (`p1-jobs ${kpi.value('p1')} | click ${clicks.join(',')} | dbl ${dbl.join(',')} | ctx ${ctx.join(',')} | changes ${changes.length}`);

fields: ['priority'] is what lets the tile's filter read priority at all on a grid-bound panel — a field a tile does not itself declare is otherwise not projected; the three pointer events each route to their matching config callback, and onChange fires once, at construction.

A host catalogue and an unmeasured tile: messages, nullText, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createKPI } = await import('../packages/modules/kpi/index.js');

const dom = createTestDom({});
const el = dom.document.createElement('div');
const HOSTS = [{ id: 1, cpu: 91, mem: 40 }];
const kpi = createKPI(el, {
  rows: HOSTS, rowKey: 'id',
  tree: { expanded: true },
  nullText: 'n/a',
  messages: {
    t: (key, params) => {
      if (key === 'kpi.status.critical') return 'kritisch';
      if (key === 'kpi.node.worst') return `schlimmster Status ${params.status}`;
      return key;
    },
  },
  tiles: [
    { id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
    // A tile whose field names no column any row carries: measures nothing.
    { id: 'compute.disk', label: 'disk', aggregation: 'max', field: 'disk' },
  ],
});
const compute = kpi.nodes()[0];
const diskTile = kpi.tile('compute.disk');
return (`${el.querySelectorAll('.lat-kpi__node')[0].getAttribute('aria-label')} | disk-formatted ${diskTile.formatted} | disk-status ${diskTile.status}`);

messages translates the node's accessible name (falling back to English for a key the host catalogue omits); nullText is what the unmeasured disk tile renders instead of a fabricated zero.

Data Router route options, executed

A per-route filter and transform, executed

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }],
});
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(grid, 'order', {
  filter: (row) => row.amt >= 20,
  transform: (row) => ({ id: row.id, type: row.type, amt: row.amt, label: `#${row.id}:${row.amt}` }),
});
router.load([
  { id: 'o1', type: 'order', amt: 10 },
  { id: 'o2', type: 'order', amt: 30 },
  { id: 'o3', type: 'order', amt: 20 },
]);
const ids = [];
for (let i = 0; i < grid.rows.count(); i++) ids.push(grid.rows.get(i).key);
return (`${ids.sort().join(',')} | label ${grid.rows.value('o2', 'label')}`);

filter gives the grid only the rows at or above 20 — the route decides what the grid ever sees, before any grid-level filter runs; transform derives the label field the columns declare, reshaping each row on the way in.

Web component

<lattice-grid> is the grid as a custom element, shipped as a self-contained module bundle. It exists for pages without a bundler, a Rails, Django or Laravel template that wants a grid without adopting a front-end build.

The whole integration

<link rel="stylesheet" href="dist/lattice-grid.min.css">
<script type="module" src="dist/modules/webcomponent.esm.min.js"></script>

<lattice-grid row-key="id"
  columns='[{"field":"id"},{"field":"city"}]'
  rows='[{"id":"A","city":"Leeds"},{"id":"B","city":"Cardiff"}]'></lattice-grid>

Load this or the main bundle, not both. The module is self-contained: it carries the grid with it, so a page that also loads lattice-grid.esm.js downloads and evaluates the grid twice.

Driven from script

// Structures are properties; scalars are attributes.
const el = document.createElement('@toclocoinc/lattice-grid');
el.setAttribute('row-key', 'id');
el.columns = [{ field: 'id' }, { field: 'charge', type: 'number' }];
el.rows = data;
document.body.appendChild(el);

// The full imperative API is on `.grid`.
el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);

Assign before appending where you can. The element builds its grid once, at the end of the task in which it connects, so everything set in that task arrives as one configuration rather than as a series of updates. Setting properties later still works: they go through the live configuration path, but the grid is built empty first and repainted after, which is a visible flash on a large set.

Reading .grid forces the build immediately, so the property is never briefly null.

Events

el.addEventListener('lattice-cell-changed', (e) => {
  console.log(e.detail.key, e.detail.colId, e.detail.value);
});

Every grid event is re-dispatched as a CustomEvent named lattice- plus the grid name with colons hyphenated, so cell:edit:start becomes lattice-cell-edit-start. The payload is event.detail. The prefix is not decoration: the grid emits an event called scroll, and an unprefixed CustomEvent of that name would be indistinguishable from the platform's own. Forwarding is a wildcard subscription, so events added to the grid later appear here with no change to the component.

Light DOM, deliberately. The element renders into itself rather than a shadow root, because the grid's generated decoration rules are injected into document.head and the theme stylesheet is a <link> the page owns: neither crosses a shadow boundary. A shadowed grid would be structurally correct and completely unstyled, and subtly so: the --lattice-* tokens do inherit through a shadow root, so the colours would arrive while every pill, bar and heat cell stayed bare. Light DOM keeps every documented theming route working unchanged.

Events

One bus, 111 events. The table below is the working set, the ones most applications actually reach for; a complete index by area follows it, and the reference lists them all.

EventCarriesUse it for
ready{}First render is done. Fires on a future turn, so you can subscribe on the line after createGrid.
cell:changed{ row, key, colId, value, oldValue, undo }Persisting an edit. undo distinguishes a rollback from a fresh change.
selection:changed{ keys }Enabling a bulk action.
range:changed{ ranges }A status bar showing the sum of what is selected: see selection.summary().
sort:changed / filter:changed{ sort } / { filters }Reflecting the view in the URL.
history:changed{ canUndo, canRedo, undo, redo }Driving your own undo button. Emitted after the entry is pushed, so the label is right.
history:applied{ direction, step }An action was undone or redone, with which and what. history:changed also fires when a new action is pushed, so it cannot distinguish the two.
form:saved{ key, values, changed, unmapped }Persisting a row edited on a form. unmapped names the fields that are not columns, which the grid reports rather than writes.
form:error{ key, error, timedOut }A row form's load failed or ran out of time. The panel stays open with a retry.
view:saved / :removed{ view, views }Persisting saved views to a server.
render:done{ first, last }Decorating cells from outside. The cell layer rewrites class names on every paint, so anything added before this is erased.

Every event, by area

The table above is the working set. This is the whole list, grouped by what it is about, so a question like "can I hear about a column being pinned?" is answerable by scanning rather than by reading the reference end to end.

Lifecycle

EventFires when
config:changedA configuration key changed. Emitted after the grid has rebuilt, so a listener reading the grid back sees the change rather than what it replaced.
destroygrid.destroy() has run.
licence:changedA key was installed, and again when verification settles.
readyFirst layout is complete and the API is safe to drive.
render:doneThe cells are written and stable. Anything decorating them from outside must run after this, the cell layer rewrites each cell's className wholesale and would otherwise erase it.
render:firstFirst paint, the number to measure time-to-first-row against.

Data

EventFires when
model:changedColumns, grouping, pivot or another structural change.
row:clickedEmitted alongside the cell event, cell first.
row:copiedA row was duplicated.
row:dblclickedA row was double-clicked. Emitted alongside the cell event, cell first.
row:edit:endIn row mode an invalid cell blocks the whole commit and the session stays open.
row:edit:startReplaces the cell pair when edit.mode is 'row'.
row:movedA row was dragged to a new position.
row:receivedA row arrived from a source.
row:sentA row was written back to a source.
rowDrag:startedA row drag began. Fires on the grid the drag started in, as do rowDrag:moved, rowDrag:left and rowDrag:ended.
rowDrag:movedThe drag is over a candidate position. Coalesced to one event per animation frame, carrying that frame's latest pointer position.
rowDrag:leftThe pointer left a grid; over names the grid it left. Emitted on the transition, not on a frame.
rowDrag:endedThe gesture ended, whether or not a drop followed, including a release outside every grid. dropped says whether the release is being acted on. Notifications only — none of the four is cancellable.
rows:changedThe row set changed. See what a change firing promises: identified means the three arrays name the rows that moved, companion marks a duplicate announcement of a change already made with identity, and a firing with neither is a real change of unknown extent.
rows:deferredUpdates were held rather than applied, because an edit is in flight.
rows:pausedA live feed was paused; updates queue from here.
rows:queuedA batched change is waiting for the next frame.
rows:resumedThe feed resumed and the queue drained.
source:errorA source or block load failed.
stream:chunkA streamed chunk landed.
stream:endStreaming finished; promoted means it switched to in-memory.
stream:evictedA streaming source dropped rows to stay within its cap.

Cells and editing

EventFires when
cell:changedA committed edit reached the data. undo distinguishes a rollback.
cell:clickedA cell was clicked. Announcement only: nothing is consumed, so editing and selection behave unchanged.
cell:confirmedThe write reached the server.
cell:conflictThe write was accepted but the server row had moved underneath it. Last-write-wins with the divergence surfaced: your value stands and serverRow carries the server's truth.
cell:contextmenuRight-click on a cell.
cell:dblclickedA cell was double-clicked. Carries the row, column, value and text.
cell:mouseoverThe pointer entered a cell. Fires once per cell, carries what cell:clicked carries plus the cell element as target, and is delegated on the viewport so it stays correct over pooled rows.
cell:mouseoutThe pointer left a cell. Fires once per cell, including when the pointer left the grid; moving to the next cell fires this first, then cell:mouseover.
cell:mousedownA pointer button went down on a cell. Carries what cell:clicked carries plus the cell element as target, and is delegated on the viewport so it stays correct over pooled rows.
cell:mouseupA pointer button was released over a cell. Same shape as cell:mousedown.
cell:edit:endIt closed: committed or cancelled.
cell:edit:startAn edit session opened.
cell:pendingApplied optimistically, not yet durable. Only with edit.commit.
cell:revertedThe write failed. applied: false means a newer edit owned the cell, so nothing was written back.
row:pendingA row was appended or deleted optimistically, not yet durable. kind is 'append' or 'delete'. Only over a source that declares mutate.append/delete.
row:confirmedThe append or delete reached the server. An appended row has already been rekeyed from its temp key to the server key, and selection, expansion, focus and in-flight cell edits followed.
row:revertedThe append or delete failed: an appended row is removed, a deleted row restored. applied: false means a newer op owned the key, so nothing was undone.
row:conflictThe op succeeded but the server row had moved underneath it. Last-write-wins with the divergence surfaced: serverRow carries the server's truth.
form:closedThe row form closed without saving.
form:errorA commit from the form failed validation or was rejected.
form:openedThe row form opened.
form:savedThe row form committed.

Columns

EventFires when
column:filter:openHeader filter popup opened.
column:profile:openThe column statistics ("describe") panel was asked to open on a column, from the column menu's "Column statistics" item ({ colId }). A mounted tool panel opens its statistics panel seeded on that column.
column:groupedThe row-group column list changed.
column:menu:openHeader menu opened.
column:movedReordered by drag or by API.
column:pinnedside is 'start', 'end' or null.
column:pivotedThe pivot configuration changed.
column:resizedA column width settled after a drag or a keyboard resize.
column:visibleColumns shown or hidden.
columns:changedThe column set was replaced or reordered wholesale.
columns:taggedA column's tags changed.

Query and view

EventFires when
facet:computedA header histogram finished counting. Carries the column and the buckets.
facet:expandedThe facet band was opened or collapsed.
facet:failedA distribution could not be computed. Carries the reason.
facet:filteredA bucket or a dragged range was applied as a filter.
filter:changedThe condition tree or the quick filter changed.
page:changedFired after the rows have moved, whether the page changed by API or by the pager control.
sort:changedThe full sort entry list.
state:changedEvery state change, whether a user gesture or a programmatic call — including a named filters.where predicate registered, replaced, removed or reapplied (BACKLOG-0001235) — announced exactly once. cause is 'user', 'apply' or 'reset'; sections names the GridState keys that moved; report lists anything a restore could not apply. A save layer subscribes to this one event and ignores cause 'reset'.
state:resetThe grid was returned to its baseline.
timeline:attachedA time brush was connected to the grid.
timeline:detachedThe brush was removed.
timeline:seekThe brush settled on a range.
timeline:seekingThe brush is being dragged. Throttled.
annotation:changedA drawing annotation was added, edited or cleared. Carries the active tool and the mark count. Declared (BACKLOG-876) so grid.on('annotation:changed', ...) and the adapters' onAnnotationChanged reach it directly instead of via the '*' wildcard.
view:appliedEmits no storage write: applying a view changes nothing to persist.
view:defaultview is null when the default was cleared.
view:removedA saved view was deleted.
view:renamedA saved view was renamed.
view:savedCarries the one view that moved: enough to POST a single record without diffing two lists.

Selection and interaction

EventFires when
detail:toggledA master-detail row opened or closed.
group:toggledA group row opened or closed.
history:appliedAn action was undone or redone. Distinct from history:changed, which also fires when a new action is pushed onto the stacks and so cannot tell you anything was reversed.
history:changedEmitted after the entry is pushed, so a toolbar reading it names the right action. Repainting from sort:changed instead reads the timeline one action behind.
range:changedCell range selection changed.
scrollThrottled to the frame.
scroll:endScrolling settled, the moment to trigger deferred work.
selection:changedThe selected rows changed. Carries the keys.
size:changedThe viewport resized.

Cancellable before-events (BACKLOG-0000943)

Every user-initiated mutation has a paired cancellable before event. The handler receives a BeforeEvent carrying the action context plus preventDefault(reason?), defaultPrevented and reason. Calling preventDefault() — or returning false, the legacy kanban onBeforeMove idiom — cancels the action. A handler may be async; the mutation is held until every registered before-handler settles, so a confirm dialog or a server check genuinely gates the write. Any one handler preventing cancels it (veto wins), and a handler that throws is treated as a cancel and surfaced. On a veto the paired <action>:cancelled event fires carrying the reason.

These fire for user actions only. Host/API writes (for example grid.edit.setCells) and remote/router-applied deltas (rows.apply, origin !== 'user') do not fire them — remote truth is not a user gesture and does not self-veto. The origin field carried on each before-event lets a host deduplicate a module-initiated write (a kanban or Gantt move that re-enters core) from a genuine user gesture. If a handler was async and the underlying state moved during the await (a row removed, a value changed by a live delta), the gate re-validates and cancels with reason 'stale' rather than applying against state that has moved. With no before-handler registered every mutation stays synchronous and behaves exactly as before.

EventFires when
beforeEditBefore a validated cell/row commit applies. Carries row, key, mode, changes, origin. Validation (edit.validate) is separate and runs first. Paired with edit:cancelled.
beforeSortBefore a sort is set. Paired with sort:cancelled.
beforeFilterBefore a structured or quick filter is set (kind tells them apart). Paired with filter:cancelled.
beforeColumnMoveBefore a column reorder applies, earlier than the post-mutation column change. Paired with columnMove:cancelled.
beforeColumnResizeBefore a column width change applies. Paired with columnResize:cancelled.
beforeColumnHideBefore one or more columns are hidden. Paired with columnHide:cancelled.
beforeSelectBefore a user selection change applies; a veto snaps back to the last announced selection. Paired with selection:cancelled.
beforeRowAddBefore an optimistic row append applies. Paired with rowAdd:cancelled.
beforeDeleteBefore an optimistic row delete applies — the canonical confirm-before-delete hook. Paired with delete:cancelled.
beforeRowMoveBefore a row reorder applies. Paired with rowMove:cancelled.
beforeGroupBefore a group/tree expand or collapse applies. Paired with group:cancelled.
beforeRowReceiveBefore a row dragged from another grid is inserted into this one; fires on the receiving grid and names the row under the pointer (overKey). A veto leaves the source grid untouched. Paired with rowReceive:cancelled.
edit:cancelledA beforeEdit was vetoed; reason is 'stale' when a live delta moved the cell during an async gate.
sort:cancelledA beforeSort was vetoed.
filter:cancelledA beforeFilter was vetoed.
columnMove:cancelledA beforeColumnMove was vetoed.
columnResize:cancelledA beforeColumnResize was vetoed.
columnHide:cancelledA beforeColumnHide was vetoed.
selection:cancelledA beforeSelect was vetoed; the selection snapped back.
rowAdd:cancelledA beforeRowAdd was vetoed.
delete:cancelledA beforeDelete was vetoed; reason is 'stale' when the row was already gone.
rowMove:cancelledA beforeRowMove was vetoed; reason is 'stale' when the row had moved.
group:cancelledA beforeGroup was vetoed.
rowReceive:cancelledA beforeRowReceive was vetoed; nothing was inserted and the source still holds the row. reason is 'stale' when the row under the pointer or the source row was gone by the time an async handler settled.
export:requestA remote export was requested. Past-tense notification.
export:doneA remote export completed. Past-tense notification.
shortcuts:openedThe keyboard-shortcuts help overlay opened. Past-tense notification.
shortcuts:closedThe keyboard-shortcuts help overlay closed. Past-tense notification.
print:beforePrint mode is about to snapshot. Past-tense notification, not cancellable (BACKLOG-0000941).
print:afterPrint mode restored the grid, even if the browser cancelled the print (BACKLOG-0000941).

Presentation and formatting

EventFires when
formatting:changedA conditional formatting rule was added, edited, reordered or restated.
highlight:changedA highlight was added or cleared.
find:changedThe find query, its matches, the current match or the bar's open state changed; carries the FindCount, partial while the sliced scan runs.
permissions:changedThe context moved and every column re-resolved.
presentation:capturedA PNG was taken.
presentation:changedThe options of a running presentation changed.
presentation:endedPresentation mode ended. Annotations are cleared here.
presentation:scaleThe presentation zoom changed.
presentation:spotlightA region was spotlit or released.
presentation:startedPresentation mode began. Carries the scale, options, views and starting index. presentation:changed covers a later change to the same options, so a listener can tell entry from adjustment.
presentation:viewThe presentation advanced to another saved view.
redaction:changedA column was redacted or restored.

Collaboration

EventFires when
comment:addedA comment was posted.
comment:deletedA comment was removed.
comment:editedA comment was changed.
comment:failedA comment could not be saved. Carries the reason.
comment:indexLoadedThe comment index finished loading, so indicators can paint.
comment:resolvedA thread was marked resolved. Carries the cellKey.
comment:threadClosedA thread was closed or resolved.
comment:threadOpenedA thread was opened in the panel.
comment:unresolvedA resolved thread was reopened. Carries the cellKey.
presence:failedA presence transport error. Presence is lossy by design; this is informational.
presence:joinedA peer was seen for the first time. Carries the peer.
presence:leftA peer disconnected.
presence:lockRefusedAn edit was refused because a peer holds the cell.
presence:publishedThis client's cursor or selection was broadcast.
presence:updatedA known peer moved or changed selection. Carries the peer.

Everything else

EventFires when
clipboard:copyA copy left the grid.
diff:changedA snapshot was set or cleared.
diff:swappedThe baseline and the current rows were exchanged.
export:progressProgress on a streamed export.
header:contextmenuA column heading was right-clicked.
toolpanel:focusThe documented keyboard shortcut reached the tool panel.
tree:loadAbortedA child fetch was cancelled, usually because the node collapsed.
tree:loadFailedA child fetch failed.
tree:loadedChildren arrived. Carries the key and the count.
tree:loadingChildren are being fetched for a node.
views:changedThe whole list, plus what moved and why.

Licensing

There is one Lattice Grid and every copy is feature-identical. No community edition, no pro tier, no feature held back behind a key. A licence removes the trial watermark; that is the whole of what it does.

Free to develop against, licensed to deploy. A grid on localhost, or any loopback host: needs no key at all. On any other domain an unlicensed grid still renders everything and carries a small trial watermark linking to latticegrid.dev.

Where it runsNo keyValid key
localhost, *.localhost, 127.0.0.0/8, ::1everything, no markeverything, no mark
any other domaineverything, trial watermarkeverything, no mark

.local, .internal and private IP ranges are not exempt. They are ordinary LAN names, and a corporate intranet is a deployment like any other.

Installing a key

LatticeGrid.setLicence('LG1.…');   // your key; setLicense also works
grid.licence.state();   // 'licensed' | 'localhost' | 'trial'

Call it once, before creating a grid. Setting a key later still works, the watermark comes off and licence:changed fires, but the first frames of the grid will carry it.

Keys come from latticegrid.dev and are issued per deployment rather than per developer or per seat: name the domains the grid will run on and one key covers every developer, every build and every user on them. A key names the domains it covers as you would expect: *.acme.com matches app.acme.com, a.b.acme.com and acme.com itself.

Checking a key needs no network. There is no licence server, no call home, and nothing that can fail at three in the morning, a key carries its own answer and the grid reads it locally, so a grid on an air-gapped network behaves exactly like one on the open internet.

Nothing ever refuses to render, and nothing is ever withheld. An expired key, a wrong domain, a key that will not read: all of them log one warning and show the watermark. Every feature keeps working. The failure to avoid is a customer's production screen going blank because a licence lapsed over a weekend, and a grid that quietly drops a feature is the same failure wearing a disguise.

Recipes

Put the view in the URL

grid.on('state:changed', () => {
  const encoded = btoa(JSON.stringify(grid.state.get()));
  history.replaceState(null, '', `?view=${encoded}`);
});

const saved = new URLSearchParams(location.search).get('view');
if (saved) grid.state.apply(JSON.parse(atob(saved)));

Save edits as they happen

grid.on('cell:changed', async (e) => {
  if (e.undo) return;                    // a rollback, not a new change
  grid.highlight({ key: e.key, colId: e.colId }, { colour: '#fff3cd', duration: 0 });
  try {
    await api.patch(`/rows/${e.key}`, { [e.colId]: e.value });
    grid.highlight({ key: e.key, colId: e.colId }, { colour: '#d4edda', duration: 900 });
  } catch {
    grid.highlight({ key: e.key, colId: e.colId }, { colour: '#f8d7da', duration: 0 });
    grid.history.undo();
  }
});

A read-only grid

createGrid(el, {
  columns, rows, rowKey: 'id',
  edit: false,
  contextMenu: false,        // the default menu offers Paste, Clear and Fill down
  columnMenu: false,         // optional: the header's 3-dot menu
  selection: { ranges: false },
});

A dashboard grid, no chrome

createGrid(el, {
  columns, rows, rowKey: 'id',
  edit: false, contextMenu: false, selection: 'none',
  rowHeight: 24, density: 'compact',
  grandTotalRow: 'bottom',
});

House-wide defaults, without patching createGrid

createGrid is exported through a getter with no setter, so LatticeGrid.createGrid = myWrapper does not replace it — silently in a plain script, with a TypeError in a module (see the reference for the exact descriptor). Own the seam yourself instead: one module that every call site imports from.

// lattice.js
import { createGrid as baseCreateGrid } from '@toclocoinc/lattice-grid';

export function createGrid(element, config) {
  return baseCreateGrid(element, { theme: 'house', locale: 'en-GB', ...config });
}

There is no shipped defaults() call that does this for you today (a separate card, BACKLOG-0001187, is considering one) — a wrapping module you own and every call site imports is the supported pattern until then.