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:
| File | What it is |
|---|---|
| lattice-grid.min.js | The whole product as a UMD build. Defines window.LatticeGrid, and works with AMD and CommonJS loaders. |
| lattice-grid.min.css | The single stylesheet. Without it the grid is in the DOM and unreadable, no widths, no scrolling, no theme. |
| lattice-grid.esm.min.js | The same, as an ES module. |
| lattice-grid.d.ts | Type 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.
| Adapter | How 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.