Grid & Layout
The Grid class is the public entry point for rendering. It receives a GridDataViewModel and renders the grid. It wraps a layout engine, a cell pool, and a selection rule store. The layout handles viewport calculation, cell placement, and DOM management.
Grid is the entry point for rendering a grid. You create a Grid instance, set a viewmodel on it, and call draw(). Internally, the Grid delegates all rendering to a layout engine - either StandardLayout (for pivot grids) or GroupedRowLayout (for flat/grouped tables). You never interact with the layout directly.
Constructor
const grid = new Grid(config, mountPoint, layoutType);config- a partialGridConfigobject. Missing fields use defaults.mountPoint- the DOM element the grid renders into. The grid attaches a shadow DOM to this element for style isolation.layoutType-"pivot"(default) usesStandardLayoutfor multi-level row/column facets with merge/span."flat"usesGroupedRowLayoutfor single-level row facets with depth-based indentation.
const mountEl = document.getElementById("grid-root")!;
// Pivot grid (default)
const pivotGrid = new Grid({ overscan: 3 }, mountEl);
// Flat/grouped table
const flatGrid = new Grid({}, mountEl, "flat");Setting data
Set a viewmodel on the grid via the data setter. This tells the layout what to render. After setting data, call draw() to trigger the first render.
const viewModel = new PivotDataViewModel(params);
grid.data = viewModel;
grid.draw();To update data, update the viewmodel and reassign it:
viewModel.updateData(newParams);
grid.data = viewModel;
grid.draw();Render cycle
grid.draw() triggers a full render cycle. Internally, the layout:
- Calculates the viewport - determines which rows and columns are visible based on scroll position, row heights, and column widths. This produces a viewport range
(x0, y0, x1, y1)plus overscan. - Fetches the data slice - calls
viewModel.getViewportData(x0, y0, x1, y1)to get only the data needed for the visible range. - Renders cells - places column facets (headers), row facets, data cells, fixture cells, and selection overlays into the DOM.
- Auto-sizes columns - measures rendered cells and adjusts column widths based on content. The sizing strategy (
"max-cell","clamped-width", or"static") comes from the viewmodel'sVTrackDefandFacetDefdefinitions. - Emits
renderComplete- fires with the viewport bounds{ x0, y0, x1, y1 }so consumers know what was rendered.
scheduleDraw() is a debounced variant - it batches multiple draw requests into a single requestAnimationFrame callback.
Cell pooling
The layout reuses DOM elements via a cell manager to avoid DOM churn during scrolling. Each cell is identified by a string key (e.g. "row-h-0-42" for a row facet cell at row 42). On each render frame:
beginFrame()- marks the start of a frame; clears the used-keys set.acquire(key)- returns an existing cell for that key, or pops one from the pool (or creates a new<div>if the pool is empty). Returns[cell, needsAppend]-needsAppendistruewhen the cell is new to this frame and must be appended to the DOM.endFrame()- any cell whose key was not used this frame is released: stripped of styles/classes/content, returned to the pool, and removed from the DOM.
This means scrolling by a few rows only creates cells for the newly visible rows while recycling cells that scrolled out of view.
Scroll handling
The layout listens for wheel events on the mount element and translates scroll position into viewport row/column indices. Scrolling is axis-locked per gesture - horizontal or vertical, not both - to prevent diagonal drift.
Each scroll event schedules a render via requestAnimationFrame, so scroll-triggered renders are throttled to the display refresh rate.
To programmatically scroll:
grid.scrollTo("row", 500); // scroll to row 500
grid.scrollTo("column", 10); // scroll to column 10scrollTo("column", n) retries up to 5 times (waiting for renderComplete between attempts) to handle cases where column auto-sizing changes the scroll geometry.
Two layout types
Both layouts share column facet rendering and corner cell rendering. They differ only in how row facets are drawn.
Column facet merging (common)
Column facets (the header rows above the data area) are merged the same way in both layouts. The merge algorithm operates on two axes:
- Primary axis (horizontal): consecutive columns at the same facet level with the same value merge into a single cell with
colspan. For example, if columns 0-3 all have "Q1" at level 0, they produce one cell spanning 4 columns. - Secondary axis (vertical): when a facet value at a deeper level is
null, the cell at the shallower level expands downward withrowspan. This happens when a higher-level grouping has no sub-grouping at that position.
| Q1 | Q2 | ||||||
| Rev | Cost | Rev | Cost | ||||
| Act | Bud | Act | Bud | Act | Bud | Act | Bud |
Level 0: Q1 and Q2 each span 4 columns (colspan=4). Level 1: Rev and Cost each span 2 columns. Level 2: leaf cells, one per column.
When a level has null values (no sub-grouping), the parent spans vertically:
| Total | Q1 | Q2 | |
| Rev | Cost | ||
Total and Q2 have null at deeper levels, so they span all 3 rows (rowspan=3). Rev and Cost are leaf cells at level 1.
Non-leaf column facet cells (those with colspan > 1) get horizontal sticky scrolling - their label stays visible as you scroll within the spanned range.
Corner cells (common)
Corner cells sit at the intersection of row facet columns and column facet rows - the top-left rectangle of the grid. Both layouts render them identically.
The corner area has numColFacetLevels rows and numRowFacetLevels columns. What goes in each cell depends on the viewmodel's facetDefs.axis:
When axis is "col" (column-oriented facets):
- All rows except the last: the column facet header (e.g. "Quarter") spans horizontally across all row facet columns (
colspan = numRowFacetLevels). Only the first column in each row renders; the rest are merged. - Last row: each column shows a row facet header (e.g. "Region", "City") - one per row facet level.
| Quarter | |
| Region | City |
2 col facet levels, 2 row facet levels. "Quarter" spans both row facet columns (colspan=2). The last row shows row facet headers.
When axis is "row" (row-oriented facets):
- First row: each column shows a row facet header that spans vertically across all column facet rows (
rowspan = numColFacetLevels). - All other rows: merged into the first row's span; not rendered.
Corner cells are sticky in both directions (they stay visible when scrolling horizontally or vertically) and are used for column width measurement of the row facet tracks.
StandardLayout - row facet merging (pivot)
Used when layoutType is "pivot" (default). Row facets use the same merge algorithm as column facets, but rotated - the primary merge axis is vertical (rows) and the secondary axis is horizontal (columns):
- Primary axis (vertical): consecutive rows with the same facet path at a given level merge into a single cell with
rowspan. - Secondary axis (horizontal): when a deeper facet level has
null, the shallower level's cell spans horizontally withcolspan.
The input data for this example (3 facet levels, 8 rows):
// rowFacets[rowIndex] = [level0, level1, level2]
const rowFacets = [
["USA", null, null ], // row 0: null at level 1,2 → spans 3 columns
["USA", "CA", "SF" ], // row 1
["USA", "CA", "LA" ], // row 2
["USA", "NY", null ], // row 3: null at level 2 → spans 2 columns
["USA", "NY", "NYC" ], // row 4
["Germany", null, null ], // row 5: spans 3 columns
["France", null, null ], // row 6: spans 3 columns
["France", "IDF", "Paris"], // row 7
];Rendered as:
| level 0 | level 1 | level 2 |
| USA | ||
| USA | CA | SF |
| LA | ||
| NY | ||
| NY | NYC | |
| Germany | ||
| France | ||
| France | IDF | Paris |
Row 0: "USA" spans 3 columns (colspan=3) because level 1 and 2 are null. Rows 1-4: "USA" at level 0 merges vertically (rowspan=4). "CA" merges rows 1-2 (rowspan=2). Row 3: "NY" spans 2 columns. Rows 5-6: single-row horizontal spans.
Non-leaf row facet cells (those with rowspan > 1) get sticky label positioning - the label stays vertically centered within the visible portion of the cell as you scroll. When a merged cell partially scrolls out of the viewport, the layout computes a translateY offset to keep the label centered in the visible area, clamped so it never leaves the cell bounds.
GroupedRowLayout - indented row facets (flat/grouped)
Used when layoutType is "flat". Extends StandardLayout and only overrides renderRowFacets(). Instead of multi-level columns with merge/span, it renders a single row facet column. Each row gets one cell; the horizontal position is indented by depth * 16px where depth comes from FlatRowMeta.
| ▼ USA |
| ▼ California |
| SF |
| LA |
| ▶ Texas |
Single column. Depth 0: no indent. Depth 1: 16px indent. Depth 2: 32px indent. Group rows (non-leaf) show expand/collapse icons.
Each row facet cell's left CSS property is set to the base position plus depth * 16px. A box-shadow fills the gap between the indent and the grid edge so there is no white space. The --depth CSS variable is set on each cell for custom styling.
The FacetDef.trackRenderer for the row facet controls what each cell looks like - expand/collapse icons, text labels, and interactive behavior are all handled by the renderer.
viewDataEmpty event
When the user scrolls beyond the loaded data range, the layout emits a viewDataEmpty event with { startRow, endRow }. This is the pagination trigger - the consumer listens for it and fetches the page covering the requested range.
The event is debounced by GridConfig.dataFetchDebounceMs (default: 150ms) to avoid flooding the data source during fast scrolling.
grid.on("viewDataEmpty", ({ startRow, endRow }) => {
// Fetch data for rows startRow..endRow from the data source
dataModel.getRows({ startRow, endRow }).then((result) => {
viewModel.updateData(result);
grid.data = viewModel;
grid.draw();
});
});See Events for all events.
GridConfig
GridConfig controls layout behavior. Pass a partial config to the constructor; missing fields use defaults.
const grid = new Grid(
{
overscan: 3,
defaultCellHeight: 24,
dataFetchDebounceMs: 200,
enableResizeUI: true,
theme: "dark",
},
mountEl,
);Configuration for the grid renderer. Pass a partial GridConfig to the Grid constructor; missing fields use defaultConfig.
Prop
Type
defaultCellHeightnumberHeight in pixels used for data rows when no VTrackDef.cellHeight override is set. Default: 19.
Type
number
defaultCellWidthnumberWidth in pixels used as the initial column width before auto-sizing measures actual content. Default: 50.
Type
number
overscannumberNumber of extra rows and columns rendered beyond the visible viewport in each direction. Higher values reduce blank flashes during fast scrolling but increase DOM node count. Default: 2.
Type
number
enableResizeUIunionWhen true, column facet cells render a drag handle on the right edge for interactive column resizing. Double-click the handle to auto-fit to content width. Default: true.
Type
boolean
fixturesobjectFixture classes to instantiate on each edge of the grid. The layout creates one instance per class and positions them in array order. See Fixtures.
Type
LayoutFixtureClasses
themestringName of the theme to apply. The layout resolves the theme via getTheme(name) and sets CSS custom properties on the grid container. Default: "light".
Type
string
columnAutosizingStrategyOnScrollunionControls how column widths update on scroll. "max-seen" keeps the widest measurement seen so far (columns only grow). "dynamic" re-measures every frame (columns can shrink when wide content scrolls out). Default: "max-seen".
Type
"dynamic" | "max-seen"
dataFetchDebounceMsnumberDebounce interval in milliseconds for the viewDataEmpty event. Prevents flooding the data source with fetch requests during fast scrolling. Default: 150.
Type
number
Default values:
{
defaultCellHeight: 19,
defaultCellWidth: 50,
overscan: 2,
enableResizeUI: true,
theme: "light",
columnAutosizingStrategyOnScroll: "max-seen",
dataFetchDebounceMs: 150,
fixtures: { top: [], left: [], bottom: [], right: [] },
}Column resize
When enableResizeUI is true (default), column facet cells render a drag handle on the right edge. Dragging the handle resizes the column(s) live:
- Leaf-level facets (the bottom header row) resize a single data column.
- Higher-level facets resize all leaf columns they span. The drag delta is divided equally across the spanned columns.
- Row facet headers resize the row facet columns.
- Double-click a resize handle to auto-fit the column to its content width.
API Reference.
Prop
Type
dataunionSets the viewmodel that provides data for rendering. After setting, call draw() to trigger the first render. Reassign after updateData() to push new data into the grid.
Returns the current viewmodel, or undefined if none has been set.
Type
GridDataViewModel | undefined
drawfunctionTriggers a synchronous render cycle: calculates the viewport, fetches the data slice, renders cells, auto-sizes columns, and emits renderComplete. Throws if data has not been set.
Type
() => void
scheduleDrawfunctionSchedules a draw on the next animation frame. Multiple calls before the frame fires are coalesced into a single render.
Type
() => void
scrollTofunctionProgrammatically scrolls to a row or column by its absolute index. For columns, retries up to 5 times to handle auto-sizing geometry changes. Throws if data has not been set.
Type
(axis: "row" | "column", absoluteIndex: number) => void
selectAllfunctionCreates a Selection builder that targets facet cells matching the predicate. Chain .selectAll() to add more predicates, then .style() or .prop() to apply effects.
Type
(predicate: FacetPredicate) => Selection
selectCellByDataIndexfunctionSelects a single cell by its data row and column index. Returns [hash, unsub] where hash identifies the selection and unsub() removes it. Returns null if the cell is already selected. Emits selectionAdded; calling unsub() emits selectionRemoved.
Type
(row: number, col: number) => SelectionResult
selectColumnByDataIndexfunctionSelects an entire column by its data index (all rows from 0 to Infinity). Column selections accumulate; adding a non-column selection clears them. Returns [hash, unsub] or null if already selected.
Type
(colIndex: number) => SelectionResult
selectRowByDataIndexfunctionSelects an entire row by its data index (all columns from 0 to Infinity). Row selections accumulate; adding a non-row selection clears them. Returns [hash, unsub] or null if already selected.
Type
(rowIndex: number) => SelectionResult
selectRangeByDataIndexfunctionSelects a rectangular range of cells. Coordinates are normalized (min/max) internally. A range selection clears all previous selections. Returns [hash, unsub] or null if already selected.
Type
(fromRow: number, fromCol: number, toRow: number, toCol: number) => SelectionResult
clearAllSelectionsfunctionRemoves all active cell/row/column/range selections and triggers a re-render.
Type
() => void