Lattice Grid Buy a licence

developer guide

Theming and density

Styling with CSS custom properties rather than overriding rules, the four built-in themes, and the density presets that follow one token.

Developer guide › Theming and density

Styling

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

By scope

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

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

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

Theming

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

A house palette

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

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

Pinning a theme, at build time or at runtime

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

Forced colours

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

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

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

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

The grid and your page's CSS

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

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

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

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

Density

One number drives the grid's geometry. A preset sets it; every token derives from it.

Four presets, or a number between them

createGrid(el, { density: 'spacious' });
createGrid(el, { density: 1.4 });   // anything between the presets
grid.set('density', 'compact');     // live
PresetScaleRowFontFor
compact0.8523.8px12.7pxThe default. Dense operational and financial grids; most rows on screen.
standard128px13pxRoomier than the default.
comfortable1.542px14pxRoomier application tables.
spacious256px15pxModern app listings with avatars and thumbnails.

Type does not scale with the rows. The row doubles from compact to spacious while the font moves about 18%. Scaling text against row height reads as a children's book long before the rows look generous, so height and spacing follow the scale directly and type is damped against it.

Virtualisation follows the token, not a separate number. Row positions are computed from the resolved --lattice-row-height, so a host that overrides that token by hand gets the virtualisation to agree. An explicit rowHeight in config outranks both, a host that names a number means it.

Height alone will not reproduce a modern app listing. Those designs pair generous rows with two-line cells (a bold title over a grey sub-label) and an avatar or thumbnail. spacious gives the room, an image column gives the picture, and twoline gives the second line.

Grid lines and corners

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

Rules between cells, and rounded corners

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

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

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

Translating the grid

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

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

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

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

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

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

Writing your own

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

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

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

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

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

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

Right-to-left

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

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

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

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

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