Lattice Grid Buy a licence

api reference

Renderers and formatting: JavaScript Data Grid API

The built-in renderers and editors, rich cell tooltips, grid.formatting and rules, in-cell charts, change highlighting, merged cells, and styling with CSS custom properties.

API reference › Renderers and formatting

Built-in renderers and editors

Both are addressable by name. Anything you register through components is addressable the same way, and a name you register wins over a built-in one.

Cell renderers, for cell.render:

NameDraws
areaA filled sparkline over a series.
barcodeA 1D barcode (Code 39, Code 39 Extended, Code 93, Code 93 Extended, Code 128, Code 11, Codabar, Code 32, EAN-13, EAN-8, UPC-A, UPC-E) or a 2D Data Matrix of the value, inline SVG. cell.props: type, value, width/height or moduleWidth, text, foreground/background, quietZone, print, checkDigit, size/encodation (Data Matrix only).
bulletA value against a target and qualitative bands.
checkboxA boolean, optionally as a switch.
colourA colour swatch with its value.
columnA column sparkline.
deltaMovement since the last value, with direction.
detailExpanderThe master-detail chevron. Generated; not usually named directly.
donutA donut chart from a series.
gaugeA value on an arc against a range.
groupThe group and tree label, with its expander and indent.
iconAn icon chosen from the value.
imageA picture from a URL. Selected automatically for type: 'image'.
lineA line sparkline.
linkAn anchor, with the text and href drawn from the row.
pieA pie chart from a series.
pillA status chip carrying a semantic variant.
progressA progress bar with an optional label.
qrcodeA QR code of the value.
rangeA span between a low and a high value.
ratingA star rating.
skeletonA loading placeholder for a row not yet arrived.
stackedA stacked proportion bar.
twolineA primary value with a secondary line beneath it.
winlossA win/loss sparkline of signed values.

The barcode renderer's symbologies - Code 39, Code 39 Extended, Code 93, Code 93 Extended, Code 128 (subsets A/B/C chosen automatically for the shortest code), Code 11, Codabar, Code 32, EAN-13, EAN-8, UPC-A, UPC-E and a 2D Data Matrix (ECC 200, square symbols, ASCII and base-256 encodation), each with its own encoder and no dependency, inline SVG output - are also available outside a grid through renderBarcode(el, options), and inside a form through the barcode display control, so a value draws pixel-identically on all three surfaces. An invalid value for its symbology (bad characters, wrong length, a failed check digit) never renders a code that would scan wrong: it shows a placeholder and warns once instead.

A standalone barcode, outside any grid, with every display option named

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { renderBarcode } = await import('../packages/dom/src/index.js');
const { root } = createTestDom();

const handle = renderBarcode(root, {
  type: 'code39', value: 'LATTICE', width: 200, height: 60, moduleWidth: 0,
  quietZone: 10, print: false, checkDigit: false,
  foreground: 'currentColor', background: null,
  text: { show: true, position: 'bottom', align: 'center', margin: 2, font: '10px sans-serif' },
});
const svg = root.children[0];
return `${svg.tagName} ${svg.getAttribute('width')}x${svg.getAttribute('height')}`;

A 2D Data Matrix, forcing a symbol size and an encodation scheme

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { renderBarcode } = await import('../packages/dom/src/index.js');
const { root } = createTestDom();

const handle = renderBarcode(root, {
  type: 'datamatrix', value: 'LATTICE-DM', size: 16, encodation: 'ascii', width: 120,
  text: { show: false },
});
const svg = root.children[0];
const [, , w, h] = svg.getAttribute('viewBox').split(' ');
return w === h ? 'SVG square' : 'SVG not square';

Editors, for edit.editor. Each column type already selects a sensible one, so naming an editor is for overriding that choice:

NameEdits
checkboxA boolean.
codeSource text, in a monospace field.
colourA colour.
dateA calendar date.
datetimeA date and a time together.
durationA length of time.
iconPickerOne icon from a set.
ipaddressAn IPv4 or IPv6 address.
multiSelectSeveral options, as chips.
numberA number, with the column's constraints.
objectPickerA record chosen from a list.
passwordA masked secret.
radixA value in its own base.
ratingA star rating.
segmentedOne of a few options, as a segmented control.
selectOne option from a list.
sliderA number on a track.
textA single line. The default.
textareaSeveral lines.
timeA time of day.
treeSelectA value from a hierarchy.
unitA quantity with a unit.

Rich cell tooltips

cell.tooltip as a string or a function gives you the browser's own title: one line of plain text, on the browser's schedule, unstyled, and invisible to a keyboard user. The object form declares a tooltip the grid draws instead, so a cell can show a related record, a small chart, a list of validation errors or an edit history. The plain-text form is untouched and still becomes a title, so an existing grid behaves exactly as it did.

render(params) returns one of four things, and the difference between the last two is a security property rather than a matter of taste:

ReturnRendered as
an HTMLElementAttached as it is. Your DOM, your responsibility.
{ title, rows, note }A TooltipSpec, drawn by the grid: a heading, label/value lines, and a closing note. Every field is written as text, so a spec built out of row values needs no escaping.
{ html: '…' }The only wrapper that inserts markup, scrubbed of script by the same rules the cell layer applies to allowUnsafeTemplates output.
a stringAlways text, whatever it contains. A string holding <b>bold</b> shows those characters; it does not embolden.

That last rule is the load-bearing one. The most natural tooltip anyone writes is render: (p) => p.value, and a value comes from row data - data the developer did not write and usually cannot audit. If a bare string were treated as markup, a name field holding <img src=x onerror=…> would execute and nothing in the code would have looked dangerous. Markup therefore has to be asked for in the source, where a reviewer can see it, and no value arriving from data can promote itself.

mount(el, params) and unmount(el) carry live content. Inside mount you call createChart or createKPI from a module bundle your application loaded - the grid core never imports a module - and unmount is called every time the tooltip closes, so nothing keeps running behind a hidden box. One tooltip element is built and re-used for every cell.

Accessibility. Nothing is built until the pointer or the keyboard cursor has rested on the cell for delay (400ms by default), so sweeping across the grid mounts nothing. Focusing a cell shows the same tooltip after the same delay and the cell points at it with aria-describedby; the tooltip can be hovered without closing, and Escape dismisses it (WCAG 2.2 AA, 1.4.13). Escape is only consumed while a tooltip is open, so it still reaches the editor, the menu and the maximised view.

It closes on scroll. Rows and cells are pooled and re-used, so a tooltip left open across a scroll would be anchored to a node that is now showing a different row. Closing is the honest answer and costs nothing: the browser re-hit-tests after a scroll, so a pointer parked over the grid simply gets a fresh tooltip for the row that is actually there. Content is resolved when the tooltip opens rather than when the pointer arrived, so it always names the row that node is showing at the moment it opens.

Grid-level defaults, and a column declaring a spec tooltip

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

const grid = createHeadlessGrid({
  tooltip: { delay: 250, maxWidth: 360 }, // the defaults for every tooltip
  columns: [
    { field: 'name' },
    {
      field: 'owner',
      cell: {
        tooltip: {
          // Returned as a spec: the grid writes every field as text.
          render: (p) => ({ title: p.value, rows: [{ label: 'Row', value: p.key }] }),
        },
      },
    },
  ],
  rows: [{ name: 'a', owner: 'Ada' }],
  rowKey: 'name',
});

const defaults = grid.get('tooltip');
const spec = grid.columns.get('owner').cell.tooltip;
const content = spec.render({ value: 'Ada', key: 'a' });
grid.destroy();
return `${defaults.delay}, ${defaults.maxWidth}, ${content.title}, ${content.rows[0].value}`;

grid.formatting

Conditional formatting rules the grid holds as runtime state, so an end user can change them. Rules travel in a saved view and undo like any other change. A scope is a column id, or '*' for every column; grid-wide rules are evaluated first, then the column's own, as one ordered list in which the first match wins.

This is distinct from compileRules() feeding cell.style, which compiles at configuration time and is what you want for rules a user should not be able to change. Both work at once: a runtime rule layers over whatever cell.style produced, winning only for the properties it names.

createGrid(el, {
  formatting: {                                   // optional seed
    margin: [{ when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } }],
  },
});

grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: 'red' } });
grid.formatting.add('*', { when: { op: 'blank' }, style: { background: '#f1f3f5' } });
grid.formatting.move('margin', ruleId, 0);        // order is meaning
grid.formatting.update('margin', ruleId, { enabled: false });
grid.formatting.remove('margin', ruleId);
grid.formatting.clear('margin');                  // or clear() for everything
MethodReturnsDescription
list(scope?)Rule[]The rules for one scope, in evaluation order.
all()objectEvery rule keyed by scope, the shape a saved view carries.
scopes()string[]Every scope holding at least one rule.
add(scope, rule, opts?)Rule | nullAppends, or inserts at opts.at. Returns the rule with its generated id.
remove(scope, idOrIndex)booleanBy id or position.
update(scope, idOrIndex, patch)Rule | nullMerges fields. The id is identity and cannot be reassigned.
move(scope, idOrIndex, to)booleanReorder, which can change which rule wins.
set(scope, rules)Rule[]Replace one scope.
replaceAll(rules)voidReplace every scope at once.
clear(scope?)voidOne scope, or all of them.
styleFor(colId, value)object | nullWhat the rules alone would paint, for an export or a preview.

A rule held here must be JSON: style may not be a function, because the rules are serialised into views and undo slices. Config-time cell.style still accepts one. Group rows are not formatted, matching the way decoration is dropped for them.

Rules that describe the data, not a threshold

gt: 100 needs somebody to know that 100 is the interesting number. Often nobody does, the interesting cells are the top decile, or the outliers, and where those fall is a property of the data rather than of the rule. These operators say that directly, and the grid works out the threshold from the column itself, over the filtered rows.

grid.formatting.add('margin', { when: { op: 'outlier' }, style: { background: '#fbeceb' } });
grid.formatting.add('qty',    { when: { op: 'topPercent', value: 10 }, style: { bold: true } });
grid.formatting.add('score',  { scale: { from: 'quantile', colours: ['#f8f9fa', '#1a6bc7'] } });

grid.formatting.distribution('margin');   // { n, min, max, mean, stddev, median, q1, q3, iqr }
grid.formatting.restat();                 // re-derive every threshold from the data as it stands
OperatorvalueMarks
topPercent10 or 0.1The top tenth of the column. Written either way; both mean the same thing.
bottomPercent10 or 0.1The bottom tenth.
topN5The five largest, ties included: three rows sharing second place in a top three all take the colour.
bottomN5The five smallest.
aboveMean / belowMean, Either side of the mean.
aboveMedian / belowMedian, Either side of the median, which is the one to reach for on a skewed column.
zAbove / zBelow2That many standard deviations from the mean. A column with no spread marks nothing rather than everything.
outlier1.5Outside Tukey's fences at that many IQRs, the same definition a box plot draws, so the marked cells are the ones its whiskers exclude.

A colour scale can take its bounds the same way, with from in place of min and max: 'minmax' spans the data, 'quantile' spans low to high (5th to 95th percentile by default), 'stddev' spans deviations either side of the mean. The quantile form is the better default on real data, one mistyped order of magnitude otherwise compresses every real value into the first swatch.

Thresholds are pinned when the rules compile and do not move on their own. That is deliberate: a boundary that re-derived itself as rows were filtered would repaint cells whose values had not changed, and nobody comparing two screenshots could tell which of the two things had moved. grid.formatting.restat() is how you move it, and a "recalculate" control is the natural place to put it.

Data bars and icon sets as rules

A rule can carry a data bar or an icon set instead of a style or a scale, so the same declarative, view-persisted, headless rule list that already paints colour scales also paints proportional bars and per-band glyphs. Both compile to a plain style object - a data bar is a CSS gradient on the background, an icon set a background-image - so they need no extra element, compose with the cell's text, and resolve the same way for a server-side export as for a browser paint. This is the rule-engine sibling of the cell decoration below; reach for a rule when you want the visual to travel in a saved view and to derive its bounds from the column's distribution.

// A data bar spanning the data, and a three-arrow icon set split at the tertiles.
grid.formatting.add('revenue', { dataBar: { from: 'minmax', colour: '#5b9bd5' } });
grid.formatting.add('score',   { iconSet: { set: 'trafficLights' } });

// Bounds and bands can be pinned instead of derived; bars that straddle zero
// grow both ways from a shared axis, in their own colours.
grid.formatting.add('delta', { dataBar: { min: -100, max: 100, colour: '#2e7d32', negativeColour: '#c0392b' } });
grid.formatting.add('rank',  { iconSet: { set: 'arrows', thresholds: [10, 20], reverse: true } });

A data bar takes min/max to pin its scale, or from: 'minmax' | 'quantile' | 'stddev' to derive it from the column; colour and negativeColour fill the two sides of a zero axis, and direction: 'rtl' reverses it. An icon set names a built-in - the keys of ICON_SETS (arrows, trafficLights, ratings) - or supplies its own icons; thresholds place the band edges, or, given none, the column is cut into equal-count bands; reverse flips the order so a high value can read as red.

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

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'revenue', type: 'number' }, { field: 'score', type: 'number' }],
  rowKey: 'id',
  rows: [1, 2, 3, 4, 5].map((n) => ({ id: 'r' + n, revenue: n * 20, score: n })),
  formatting: {
    revenue: [{ dataBar: { from: 'minmax', colour: '#5b9bd5' } }],
    score: [{ iconSet: { set: 'trafficLights' } }],
  },
});

// A data bar compiles to a gradient the cell layer paints as a background.
const bar = grid.formatting.styleFor('revenue', 100).backgroundImage.includes('linear-gradient') ? 'bar' : 'none';

// An icon set resolves different bands to different glyphs.
const low = grid.formatting.styleFor('score', 1).backgroundImage;
const high = grid.formatting.styleFor('score', 5).backgroundImage;
const icon = low !== high ? 'icon' : 'flat';

const sets = Object.keys(ICON_SETS).length;   // the three built-in sets
grid.destroy();
return [bar, icon, sets].join('|');

Runtime decorations: data bars and icon sets on demand

Where a colour rule paints the cell's background, a decoration changes the shape the cell renders as, a data bar sized by value, or a threshold icon set. grid.columns.decorate(id, spec) turns one on, changes it, or clears it with null, after the grid is built. It is presentation config rather than query state: unlike a formatting rule it is not on the undo timeline and does not travel in a saved view. Icon sets carry an aria-label per band and keep the value beside the glyph, so the meaning is announced, never only shown.

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

const grid = createHeadlessGrid({
  columns: [{ field: 'score' }, { field: 'trend' }],
  rows: [{ id: '1', score: 72, trend: 8 }],
  rowKey: 'id',
});

// A data bar, sized 0..100, set at runtime.
grid.columns.decorate('score', { type: 'bar', min: 0, max: 100 });
const bar = grid.columns.get('score').cell.decoration.type;

// A built-in three-arrow icon set on the trend column, with its own bands.
grid.columns.decorate('trend', { type: 'icon', bands: [
  { min: 0, icon: 'chevronUp', label: 'increasing', variant: 'success' },
  {         icon: 'chevronDown', label: 'decreasing', variant: 'danger' },
] });
const set = grid.columns.get('trend').cell.decoration.type === 'icon' ? 'arrows' : 'none';
const band = grid.columns.get('trend').cell.decoration.bands[0].label;

// Clearing a decoration returns the column to plain text.
grid.columns.decorate('score', null);
const cleared = grid.columns.get('score').cell.decoration === undefined ? 'cleared' : 'still-set';

// A runtime colour rule is the durable, view-persisted sibling.
grid.formatting.add('score', { when: { op: 'lt', value: 50 }, style: { background: '#fdecea' } });
const painted = grid.formatting.styleFor('score', 20) ? 'painted' : 'plain';

return [bar, set, band, cleared, painted].join('|');

grid.highlight

One mechanism for two jobs: the flash a changed cell makes, and a marker you paint deliberately. A target is a cell ({key, colId}), a row ({key}, or a bare row key) or a column ({colId}). Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over it.

createGrid(el, {
  highlightOnChange: { colour: '#ffe08a', duration: 1200 },   // or just true
});

grid.highlight({ key: 'r1', colId: 'cap' }, { colour: 'green', duration: 800 });
grid.highlight({ key: 'r3' }, { colour: '#fdeaea', duration: 0 });   // 0 = until cleared
grid.highlight({ colId: 'margin' }, { colour: '#e7f1fd', duration: 0 });
grid.highlight.clear({ key: 'r3' });
grid.highlight.clear();                                             // everything
MethodReturnsDescription
highlight(target, opts?)booleancolour (or color) and duration in milliseconds. duration: 0 stays until cleared.
clear(target?)booleanOne target, or every highlight when called with nothing.
list()object[]Every active highlight and its remaining duration.
colourFor(key, colId)string | nullWhat a given cell is painted, after precedence.

A highlight belongs to the row, not the element. Rows are recycled as you scroll, so highlights are reapplied after every paint, they survive scrolling, sorting, filtering and paging without any of them knowing highlights exist.

Conditional formatting

Rules compile into the function cell.style already takes, so a compiled rule set installs exactly like a hand-written style function.

import { compileRules } from '@toclocoinc/lattice-grid';

{ field: 'margin', cell: { style: compileRules([
  { when: { op: 'lt', value: 0 },              style: { background: '#fdecea', colour: '#b91c1c' } },
  { when: { op: 'between', value: 0, value2: 5 }, style: { background: '#fdf3e0' } },
  { scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } },
]) } }
KeyDescription
when{ op, value, value2 }, using the same operators as filters: eq, ne, gt, gte, lt, lte, between, outside, contains, notContains, startsWith, endsWith, blank, notBlank, true, false.
styleA style object, or a function of the cell params.
scale{ min, max, colours }, a colour scale. Two or more stops, reached evenly.
stopIfTrueDefault true. false lets a later rule add to this one.
enabledfalse skips the rule without removing it.

Rules are evaluated in order and the first match wins, as in a spreadsheet: "red if overdue, amber if due this week" reads top to bottom and stops. A blank cell satisfies no comparison, so an empty cell is not swept into "less than 100".

A scale's min and max are required rather than derived from the data. A scale that rescaled as rows were filtered would change a cell's colour without its value changing.

In-cell charts

Eleven chart renderers for a cell. Each is a single SVG whose path data is the only thing a repaint writes, so they cost the same as any other cell as rows recycle.

NameShowsReads
lineTrend across a series.An array
areaTrend, with the area beneath filled.An array
columnA bar per point, drawn from zero.An array
winlossOne equal mark per point, up or down.An array
pieHow a set of numbers divides.An array
donutThe same, with a hole.An array
bulletOne measure against a target, over bands.A number
stackedHow one row's total divides, across the cell.An array
rangeThe span a set of values covers, middle marked.An array
gaugeOne value as a dial.A number
deltaDirection and movement over a sampling interval.A number
{ id: 'trend',  field: 'readings', cell: 'line' }
{ id: 'spend',  field: 'monthly',  cell: { render: 'column', props: { min: 0, max: 100 } } }
{ id: 'mix',    field: 'split',    cell: { render: 'donut', props: { hole: 0.55 } } }
{ id: 'sla',    field: 'uptime',
  cell: { render: 'bullet', props: { target: 80, bands: [60, 85], max: 120 } } }

// When the series lives on another property than the cell's value.
{ id: 'trend', field: 'latest', cell: { render: 'line', props: { series: 'readings' } } }
PropApplies toDescription
seriessparklinesProperty name holding the array, when it is not the cell's value.
min / maxallPin the scale so several columns compare like for like. Also the name of a row property (read from the row like series) or a function (p) => number, to pin each row to its own baseline band; a value that is not a finite number falls back to the autoscale for that bound only.
labelallfalse hides the number beside the chart.
markerline, areafalse hides the dot on the last point.
holedonutInner radius as a fraction, default 0.55.
targetbulletDraws the target marker.
bandsbulletEdges of the qualitative bands, e.g. [60, 85].
intervaldeltaMilliseconds between samples. Default 1000.
modedelta'change' (default) or 'against'.
againstdeltaProperty to compare with in against mode.
showdelta'both', 'arrow' or 'delta'.

Entries that are not numbers are gaps rather than zeroes: a line breaks across them and a bar is omitted. Pin min and max when comparing columns, a sparkline scaled to its own data fills its cell whatever the magnitude.

The chart is aria-hidden and the cell carries a text summary, so a screen reader is told "12 points, 9 to 20, ending 18" rather than each value in turn.

A sparkline pinned per row to a baseline band: min/max as a row field, so equal relative moves draw equal whatever the magnitude

const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { root } = createTestDom({ width: 800, height: 300 });

// Three magnitudes - 40, 4,000, 400,000 - each pinned by field to its own
// ±15 % band, so the same relative move lands the same fraction of the cell.
const grid = createGrid(root, {
  rowKey: 'id',
  rows: [
    { id: 'a', lo: 34, hi: 46, readings: [36, 40, 44] },
    { id: 'b', lo: 3400, hi: 4600, readings: [3600, 4000, 4400] },
    { id: 'c', lo: 340000, hi: 460000, readings: [360000, 400000, 440000] },
  ],
  columns: [{ field: 'readings', cell: { render: 'line', props: { min: 'lo', max: 'hi' } } }],
});
flushFrames();

// Normalise each line's last point against the 24px drawing space: all three
// end at the same fraction because the scale is pinned per row.
const frac = [...root.querySelectorAll('.lat-chart__line')]
  .map((p) => {
    const d = p.getAttribute('d');
    const pts = d.match(/[ML][\d.]+ [\d.]+/g);
    const y = Number(pts[pts.length - 1].slice(1).split(' ')[1]);
    return ((22 - y) / 20).toFixed(2);
  })
  .join(' ');
grid.destroy();
return frac;

Built-in names

Every registry accepts a custom entry under the same name, which then wins over ours.

Data types

Seven built-in, and seventy-four in the extended catalogue. Inference only ever reaches the built-in names: the candidates are tried in registration order, and every string settles on text and every number on number before an extended type is reached. So a column asks for an extended type by name.

textnumberbooleandatedateStringlookupobject
timedatetimetimestampduration ipv4cidripv6 jsonsecret hexhex8hex16hex32 binarybinary8octaldecibeldecibelAmplitude bytesmegabytesgigabytes bitrategigabits metresmillimetreskilometres gramskilogramstonnes secondsmillisecondshours speedkphmphknots accelerationareahectares volumecubicMetres energykilowattHours powerkilowattsforce pressurebarpsi torquedensity flowlitresPerMinute radiansdegrees voltagecurrentresistance capacitanceinductancecharge conductancefluxDensity luminousFluxilluminancesubstance absorbedDoseequivalentDoseradioactivity frequency luminousIntensitydoseRaterpmangularVelocityppmppbbasisPointsmolaritymassFlowtonnesPerHourviscositykinematicViscositythermalConductivityspecificHeatcelsiusfahrenheitkelvincurrencyusdeurgbpjpy

Editors

texttextareanumberdatecheckboxselectmultiSelect timedatetimedurationipaddresspasswordcode unittemperaturecurrencyradixsliderratingsegmented treeSelectobjectPickericonPickercolour

Cell renderers

groupcheckboxprogresslinkpilliconskeletonratingcolourqrcode

Filters and aggregations

textnumberdatesetmultiadvanced
summinmaxavgcountcountValuesfirstlast

Icons

Inline SVG sprites, overridable by name through registerIcon(name, def).

chevronRight chevronDown chevronUp chevronLeft check dash close plus minus info success warning danger clock calendar lock link external filter pause play chart palette undo redo columns download restore spreadsheet print maximise minimise views search pencil trash share pin sortAsc sortDesc menu drag star heart circleFilled square bolt flag arrow highlight eraser thumbUp eye eyeOff copy present cabinet splice exchange pole chamber premises fault user blank

Styling and your page's CSS

Forced colours. In Windows High Contrast Mode the grid translates state that is normally a background tint into borders and system colours: selection takes the system's own selection colours, pinned regions swap their shadow for a rule, and diff states are told apart by border style rather than by hue. A colour swatch and a collaborator's presence colour keep their own colour, because there the colour is the information.

Every selector is namespaced under .lattice, so the grid cannot restyle your page. Since 1.4.0 the reverse holds too: the elements the grid builds are given a floor for the properties a host page commonly sets on a bare tag: margin, padding, border, radius, background, shadow, text transform, letter spacing, and type and colour on form controls. A rule such as section { padding: 5.5rem 0 } no longer reaches inside the grid.

No !important is involved. The reset is specificity (0,1,1); every rule that dresses a grid element is (0,2,0) or higher, and so is any rule of yours aimed at a Lattice class. Deliberate overrides work exactly as before: only bare-tag rules are shut out. The reset covers box model and decoration only, never display, position or any dimension.

Merged cells

rowSpan(p) and colSpan(p) on a column say how many rows or columns this cell covers. The grid paints the cell once, at its origin, sized across the area it covers; the cells underneath it are not painted at all. Return 1 - or nothing - for no merge, which is the default and costs nothing: a grid where no column declares either callback never runs the merge pass.

The parameter is the same CellParams bag a formatter or a cell renderer gets, so p.index is the display row, p.data the record and p.value the cell's value.

Where a merge stops. A column merge is clipped at a pinned-region boundary - the start, centre and end regions scroll independently, so one cell cannot straddle two of them - and at the last column. A row merge is clipped at a group heading, at an expanded detail panel, and at the last row. Nothing is refused; the merge is drawn as far as it can honestly reach.

A merge is one cell everywhere else, too. The origin carries aria-rowspan and aria-colspan. An arrow key into a merged cell lands on its origin and an arrow out leaves from its far edge. A range that is drawn on a merged cell takes the whole of it. Editing edits the origin. The fill handle refuses to start on one: there is no single source value for a block, and nowhere to write the cells it covers. CSV, the clipboard and Excel write the value at the origin and blanks in the covered cells - and Excel writes a real <mergeCells> range, so the file opens merged.

A merge follows the rows, so keep it meaningful. The callbacks are evaluated on every render against the rows as they are then ordered. Sort or filter the grid so that the rows a merge covered are no longer adjacent and the merge changes with them - which is correct, and is why the usual pattern merges only on the column the grid is sorted or grouped by. Nothing auto-merges equal neighbours for you; the recipe for that is two lines and is below, because the grid cannot know whether two equal values in a re-sorted view are still the same thing.

The renderer looks back 50 rows above the viewport to find a merge whose origin has scrolled off the top, so a merge taller than that is reported once through warnOnce and drawn from the first row in range. A merge that spans a region boundary is clipped, not refused, and a callback that throws or returns something that is not a number is treated as no merge.

A reporting grid: a category cell spanning its rows, and a full-width note

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

const data = [
  { id: 1, cat: 'North', item: 'a', n: 1, flag: 'ok' },
  { id: 2, cat: 'North', item: 'b', n: 2, flag: 'ok' },
  { id: 3, cat: 'North', item: 'c', n: 3, flag: 'ok' },
  { id: 4, cat: 'South', item: 'note', n: 0, flag: '' },
];

const grid = createHeadlessGrid({
  rowKey: 'id',
  rows: data,
  columns: [
    // The category cell covers its three rows: painted once, at the top of the run.
    { field: 'cat', rowSpan: (p) => (p.index === 0 ? 3 : 1) },
    // A full-width note: one cell across every column to its right.
    { field: 'item', colSpan: (p) => (p.data.item === 'note' ? 3 : 1) },
    { field: 'n' },
    { field: 'flag' },
  ],
});

// The same map the renderer and every export drive.
const rows = [];
grid.rows.forEach((row) => rows.push(row));
const map = buildSpanMap({
  from: 0,
  to: rows.length - 1,
  rowCount: rows.length,
  columns: grid.columns.visible(),
  rowAt: (i) => rows[i],
  params: (row, column, index) => ({ data: row.data, row, column, colId: column.id, index }),
});
const cat = map.origins.get('0:0');
const note = map.origins.get('3:1');

// CSV writes the value once, at the origin, and blanks in the covered cells.
const csv = grid.export.csv({ headers: false, lineEnding: '|' }).replace(/\|$/, '');
grid.destroy();
return `cat 0 spans ${cat.rows}; note 3 spans ${note.cols} cols; ${csv}`;

Auto-merging equal adjacent values - the two-line recipe

// The grid does not do this for you on purpose: after a re-sort, two equal
// values next to each other are not necessarily the same thing. When they are,
// this is the whole of it - look up, and if the row above shares the value,
// you are not the origin; if it does not, count down while the value holds.
const runSpan = (field) => (p) => {
  const at = (i) => p.grid.rows.get(i)?.data?.[field];
  if (at(p.index - 1) === p.data[field]) return 1;
  let n = 1;
  while (at(p.index + n) === p.data[field]) n++;
  return n;
};

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
  rowKey: 'id',
  rows: [
    { id: 1, cat: 'North' }, { id: 2, cat: 'North' },
    { id: 3, cat: 'North' }, { id: 4, cat: 'South' },
  ],
  columns: [{ field: 'cat', rowSpan: runSpan('cat') }],
});
const span = grid.columns.get('cat').rowSpan;
const rows = [];
grid.rows.forEach((row) => rows.push(row));
const spans = rows.map((row, index) => span({ grid, data: row.data, index }));
grid.destroy();
return spans.join(',');