Lattice Grid Buy a licence

developer guide

JavaScript Data Grid Developer Guide

Install the grid, build your first one, then read the guide by topic. Start here, and use the API reference to look things up.

Download Lattice Grid The built files and the docs, from the public repository.

Version 1.103.2 Zero dependencies No build step

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. How it works goes through the layout in full, including what it costs and where it does not help.

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.latticegrid.dev/lattice-grid@1.103.2/lattice-grid.min.css">
<script src="https://cdn.latticegrid.dev/lattice-grid@1.103.2/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);

Our CDN serves every file of the package at cdn.latticegrid.dev/lattice-grid@1.103.2/<file>. The version in the address is the exact release, so a page in production never picks up a different build. 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:

FileWhat 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-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,
// createLatticeCalendar, createLatticeGantt, createLatticeScheduler, createLatticeShell, createLatticeForm, createLatticeLayout, createLatticeTabs,
// createLatticeDesigner, 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, createLatticeCalendar, createLatticeGantt, createLatticeScheduler, createLatticeShell, createLatticeForm, createLatticeLayout, createLatticeTabs and createLatticeDesigner each return one component; createLatticeViewer is the generic behind them, for anything not yet named, and createViewerController is the framework-free lifecycle underneath. createLatticeDesigner is the designer's own component: its state is controlled when the host passes it, and it is driven through the framework-free createDesignerController, the same lifecycle shape the viewer table uses.

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.

React: migrating a data-grid page (1.80)

import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeDataGrid } from '@toclocoinc/lattice-grid/modules/react';

// Was: import { DataGrid } from '@mui/x-data-grid';
const DataGrid = createLatticeDataGrid({ React, ReactDOM, createGrid });

const columns = [
  { field: 'id', headerName: 'ID', width: 90 },
  { field: 'firstName', headerName: 'First name', width: 150, editable: true },
  { field: 'age', headerName: 'Age', type: 'number', width: 110 },
  { field: 'fullName', headerName: 'Full name', flex: 1, sortable: false, filterable: false,
    valueGetter: (value, row) => `${row.firstName} ${row.lastName}` },
];

function People({ rows }) {
  const apiRef = React.useRef(null);
  const [paginationModel, setPaginationModel] = React.useState({ page: 0, pageSize: 25 });
  const [sortModel, setSortModel] = React.useState([{ field: 'age', sort: 'desc' }]);
  return (
    <DataGrid
      apiRef={apiRef}
      rows={rows}
      columns={columns}
      checkboxSelection
      disableRowSelectionOnClick
      paginationModel={paginationModel}
      onPaginationModelChange={setPaginationModel}
      sortModel={sortModel}
      onSortModelChange={setSortModel}
      onRowClick={(params) => open(params.id)}
      slots={{ toolbar: () => <button onClick={() => apiRef.current.exportDataAsCsv()}>CSV</button> }}
      showToolbar
    />
  );
}

One import changes; the page does not. createLatticeDataGrid returns a component that takes the free-tier <DataGrid> props and maps each onto the grid: rows, columns, getRowId, loading, density, checkboxSelection, disableRowSelectionOnClick, editMode, the controlled models sortModel, filterModel, rowSelectionModel, columnVisibilityModel and paginationModel with onSortModelChange, onFilterModelChange, onRowSelectionModelChange, onColumnVisibilityModelChange and onPaginationModelChange, pageSizeOptions, the events onRowClick, onCellClick, onCellDoubleClick, onCellEditStop, onRowEditStop, onColumnResize, onColumnOrderChange, onCellModesModelChange, onRowModesModelChange and onDataSourceError, plus apiRef, slots (the toolbar) with showToolbar, className and style. A column definition's field, headerName, width, flex, type, valueGetter, valueFormatter, renderCell, editable, sortable and filterable are mapped, with MUI's argument order. apiRef.current carries getSelectedRows, setSortModel, setFilterModel, scrollToIndexes, exportDataAsCsv and updateRows.

Anything else is refused by name, never guessed at. A prop the component does not map - sx, initialState, a Pro prop such as rowGroupingModel, an unknown column key - prints one react:mui-prop-unsupported:<prop> warning and is ignored. A filter model holding one operator it cannot translate is not applied at all, because applying the rest would show rows nobody asked for.

Paging is MUI's only over a memory source. MUI pages rows the browser already holds. Give the component a Lattice source (a pushdown engine, a paged endpoint) and the page belongs to the source: the three paging props are refused by name and the rows are left as the source delivers them. createDataGridController is the framework-free half underneath, for driving the same translation without React. The component is React-only by design: it is a migration path off a data-grid package, which the other frameworks do not need.

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.

Vue 3: every viewer, not only the grid (1.66)

// lattice.js
import * as vue from 'vue';
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 { createLatticeVue } from '@toclocoinc/lattice-grid/modules/vue';

// createLatticeVue builds whichever components the factories you pass support.
// The individual factories are there too, if you want only one:
// createLatticeGrid, createLatticeKPI, createLatticeChart, createLatticeKanban,
// createLatticeCalendar, createLatticeGantt, createLatticeScheduler, createLatticeShell, createLatticeForm, createLatticeLayout, createLatticeTabs,
// createLatticeDesigner, createLatticeGridContext, createLatticeRouter, createLatticeViewer.
export const L = createLatticeVue({
  vue, createGrid, createKPI, createChart, createTabs, createDataRouter,
});
// Dashboard.vue
<script setup>
import { shallowRef } from 'vue';
import { L } from './lattice.js';

L.provideLatticeGrids();
L.provideLatticeRouter(ROUTER_CONFIG);

const tab = shallowRef('all');
const updates = shallowRef(null);
</script>

<template>
  <component :is="L.LatticeTabs" :active="tab" :tabs="TABS" @tab-changed="tab = $event.id">
    <template #all>
      <component :is="L.LatticeGrid" name="all" route="all" v-bind="CONFIG" :row-updates="updates" />
    </template>
    <template #big>
      <component :is="L.LatticeGrid" name="big" route="significant" v-bind="CONFIG" />
    </template>
  </component>

  <component :is="L.LatticeKPI" grid-name="all" :tiles="TILES" :columns="5" />
  <component :is="L.LatticeChart" grid-name="all" type="bar" x="region" y="count" @click="pick" />
</template>

Every viewer is a component now. Before 1.66 the adapter wrapped createGrid and nothing else, so a Vue application wrote its own onMounted/onBeforeUnmount pair 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, createLatticeCalendar, createLatticeGantt, createLatticeScheduler, createLatticeShell, createLatticeForm, createLatticeLayout, createLatticeTabs and createLatticeDesigner each return one component definition; createLatticeViewer is the generic behind them, for anything not yet named, and createViewerController is the framework-free lifecycle underneath. Everything is built with defineComponent and render functions, so no template compiler is needed in your build and vue.runtime.* is enough. The floor is Vue 3.3, and Vue 2 is not supported.

A viewer finds its grid through provide, not a template ref. A KPI panel and a chart are built against a grid instance, and that instance does not exist until after the first render. Assigning to a template ref re-renders nobody, so a sibling never learns the grid arrived. createLatticeGridContext returns { LatticeGridProvider, provideLatticeGrids, useLatticeGrid }: call provideLatticeGrids() in a parent's setup (or wrap the subtree in <LatticeGridProvider>), a grid publishes itself under its name, a viewer takes it by grid-name, and the viewer is built the moment it appears. A page with one grid names nothing.

A tab's content is a slot. createLatticeTabs keeps the module's tablist semantics, keyboard handling, lazy first mount and live badges, and renders each tab's named slot - <template #all> for a tab with id: 'all', or <template #tab-all> - into the module's own panel element through a <Teleport>. So a tab's grid is a real component with props, a ref and the surrounding provides. A tab with no slot is left to the module, so both kinds mix on one strip.

The router belongs to a scope. createLatticeRouter returns provideLatticeRouter, which you call in a component's setup. The router is built lazily by the first grid beneath it that names a route, and destroyed when that component's scope is disposed - so it lives exactly as long as the page that owns it. The configuration is read once: rebuilding would drop every attached grid and every row held. A grid detaches before it is destroyed, so the router never holds a dead grid, and useLatticeRouter() hands any descendant the same handle.

A live feed needs no ref. row-updates 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 exposed instance. VIEWER_EVENTS and viewerHandlerName are the matching event surface (card:move, which a template binds as @card-move), and DEFAULT_GRID_NAME is the name a grid publishes under when you choose none.

Reactivity: shallow, and measured on a grid event. Hold a large rows array in a shallowRef and assign a new array when it changes; a plain ref makes every row object a reactive proxy, which the grid neither needs nor wants. Nothing in the adapter uses a { deep: true } watcher, for the same reason - one watcher per prop, compared with Object.is. And the grid paints on its own schedule rather than Vue's, so nextTick() means "Vue has patched", not "the grid has painted": measure in @render-done or @rows-changed.

Client only. Every component owns a real DOM element and builds nothing before onMounted, so a server render emits the empty host: there is no SSR or Nuxt server-side support. Every component destroys its instance, stops its watchers and unsubscribes its listeners in onBeforeUnmount, so a v-if toggled twice leaves exactly one instance alive.

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>

Since 1.66, components too. An action cannot express what the other adapters express - it takes a node and a value, so it has no children, no snippets and no context - so the package also ships a Svelte 5 component per viewer as .svelte source at @toclocoinc/lattice-grid/svelte/*.svelte, compiled by your own toolchain, with svelte as an optional peer dependency. Wrap a subtree in GridProvider.svelte and a grid publishes itself under its name for a chart or a KPI panel beside it to find; wrap one in Router.svelte and a grid that names a route is attached to a Data Router built with the first of them. Both are built from exported helpers you can call yourself - createGridRegistry() returns the registry a provider puts in context, and createRouterHandle({ createDataRouter }) returns the router handle, built on first use and destroyed with the component that owns it. See Svelte 5: components you compile. The action below is unchanged and still supported.

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.

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

The guide, by topic

Each topic is its own page, so you can link to the one that answers the question in front of you.

  • Columns and cell rendering

    Defining columns, the built-in types and formatting, the cell renderers, rich cell tooltips, charts, group rows you draw yourself, and the formatting rules that colour a cell by its value.

  • Sorting, filtering and find

    Single and multi-column sort, the filter menu and condition tree, quick filter, header histograms, a Ctrl+F find bar that highlights every match without touching what is shown, and grouping with totals and pivot.

  • Editing

    Inline and row editing, validation, optimistic writes with rollback, cell ranges, the fill handle, and formulas typed into a cell.

  • Sources and pushdown

    The memory, paged, remote and streaming sources, pushing the grid's query down to an engine, high-rate live updates and wiring a WebSocket feed into the data router yourself, and grids built from other grids: derived, stacked into one list, joined, cross-filtering, and one grid holding the correlations, the time-series read or the comparison of another.

  • Statistics and capability

    What the grid knows about its own numbers: column profiling, process control and capability, confidence intervals, the tiles that put a figure above the table, and a summary that counts and filters the rows that look wrong.

  • Tree data and master-detail

    A hierarchy from a parent reference or a path, orphans and cycles absorbed rather than dropped, lazy children, and rows that expand into a nested grid.

  • Collaboration and comments

    Live presence and cursors, threaded cell comments, column permissions against a user context, audit and diff mode, and redacting a column for a screen share.

  • Accessibility and keyboard

    Built to WCAG 2.2 level AA: the full keyboard map, roving focus over a virtualised body, the ARIA the grid writes, and forced-colours support.

  • Theming and density

    Styling with CSS custom properties rather than overriding rules, the four built-in themes, the density presets that follow one token, and the layout details: grid lines, vertical alignment and always-visible scrollbars.

  • Presenting and saved views

    Presentation mode for a room, full-screen maximise, the time scrubber, saved views, undo and redo across the whole grid, and the left tool rail.

  • Export and clipboard

    Excel and CSV export written with no dependency, bringing rows in from a CSV or a pasted block with a preview, clipboard interop with Excel and Sheets, the <lattice-grid> web component, and the full event API.

  • The htmx integration

    Hydrating a server-rendered table into a grid, surviving htmx swaps, and driving sort, filter and infinite scroll as htmx requests the server answers.

  • Grid API compatibility

    A compatibility wrapper shaped like the grid API an existing application already calls, so the constructor and the data, selection, history, export and event namespaces keep working after an import swap.

  • Licensing and recipes

    How the licence key works, driving the grid from a language model through the intent layer, worked recipes for the shapes that come up most, and the rest of the surface in one place.

Frameworks and modules

Using the grid from a framework, and the two optional modules. The adapters are factories you hand your framework and createGrid to, so they add no dependency and no second copy of the grid.

  • React

    Install the adapter, the factory pattern, props through the public API, cleanup and TypeScript.

  • Vue

    The Vue 3 adapter as a factory, reactive props, events, and cleanup on unmount.

  • Svelte

    The Svelte adapter as a use: action, so no framework runtime is passed in.

  • Angular

    The Angular adapter as a standalone component, config through one input, events as outputs, and the live grid on a getter.

  • Web components

    A custom element per view for a page with no bundler, shadow roots, and the one-or-the-other rule.

  • Charts

    The charts module, createChart, all 78 chart types, following the grid and click-to-filter.

  • Geospatial

    The geometry column type, its spatial operators, DuckDB pushdown, maps that read a geometry column, the viewport filter, and chart provenance.

  • deck.gl

    bindDeck: a deck.gl layer as one more viewer of the grid, fed exactly the rows it shows, with the map’s own pan and zoom written back as a filter.

  • Leaflet

    bindLeaflet: a Leaflet layer as one more viewer of the grid, the same two-way contract as bindDeck, with a click on a feature selecting its row.

  • Map View

    createMapView: a 3D map beside the grid, drawing its rows as markers, extruded columns, polygons and routes, that filters, searches and selects in step with the table.

  • Dashboard

    createDashboard: a whole dashboard from a JSON spec, a grid, chart, KPI, map and html panels wired to sources and to each other, saved and restored by spec(), and proposed by the AI.

  • Designer

    createDesigner: a dashboard builder for your own users, with sources and relationships, guardrails, saved state, derived grids, statistics widgets and AI assist through your own model.

  • Lists

    The list column type: chips, a token editor, and filters that ask about the whole list, in the browser and pushed into a DuckDB LIST column.

  • Derived and chained grids

    A grid built from another and following its filter: grouping, unnesting, profiles, chaining to any depth, and tiles that read the same rows.

  • Pushdown sources

    Push the grid's query down to DuckDB, an OData service or your own API, take back only what it asked for, finish the rest locally, and see the split.

  • Statistics and shadow columns

    grid.statistics, the panel, shadow columns and process capability.

  • Data types and units

    The eighty-eight technical types, unit configuration, and registering a family of your own.

  • Right-to-left and Arabic

    An Arabic, Hebrew or Farsi locale lays the grid out right to left: what mirrors, logical against physical alignment, the Arabic catalogue, and the views that stay left to right.

  • Design-system presets

    Four ready-made stylesheets (Material 3, Bootstrap 5, Ant Design 5, Fluent 2), themeFromMui for a live MUI theme, and the 171-token contract every preset restyles against.

  • Custom adapter contract

    The precise contract a hand-written pushdown adapter implements: the capabilities you declare, the request and answer shapes, refusal by name, abort, export mode and the write-back bridge.

Working in Python? The grid also ships as a Jupyter widget and a Dash component, both installable from PyPI, so you can show and edit a pandas DataFrame in a notebook or a Dash app and read every edit back with its dtypes intact. See the Python data grid.

For every optional bundle in one place, npm import, CDN path, browser global and size, see the module map.