Column Properties
Configure each column of a grid - width, row height, header content, and how cells draw.
Every column in the grid carries its own display settings: how wide it is, how
tall its rows are, and how each cell draws. These live in a per-column track
definition (vTrackDefs). A column's header is configured separately, as part
of facetDefs, which can describe several facet layers in a single definition.
A facet is just the grid's word for a header - the two are interchangeable.
We use the generic term because facets aren't only drawn across the top: in a
pivot they also run down the side to label rows. So facetDefs covers both.
grid.data = new FlattenedDataViewModel({
...
options: {
// one entry per column: width, row height, and how each cell draws
vTrackDefs: [{ colSize, cellHeight, renderer }, /* ...one per column */],
// header content (icons, badges) is configured here, not on the column
facetDefs: { row: [], col: [{ text: "", trackRenderer }], axis: "col" },
},
});Column size
A column can fit its widest cell, take a static track width, or size to content
within a min/max clamp - all set through colSize.
// fit to the widest cell
{ strategy: "max-cell" }
// content-sized, but clamped - cap how wide it grows, or give it a floor
{ strategy: "clamped-width", maxWidthInPx: 160 }
{ strategy: "clamped-width", minWidthInPx: 240 }
// a static track width - `unit` is "px", "fr", or "%"
{ strategy: "static", width: 1, unit: "fr" }Try it below: switch modes in the toolbar. The clamp mode caps the Title column at 160px and holds Base Salary to at least 240px, while the rest fit content.
Source
import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import type { ColAutoSizeConfig } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { createToolbar, toolbarSelect } from "../../runtime/toolbar";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow } from "../../types";
// A simple flat table (no pivot, no grouping). `field` reads from the payroll
// rows; `label` is the column header.
const COLUMNS = [
{ field: "agency_name", label: "Agency" },
{ field: "title_description", label: "Title" },
{ field: "base_salary", label: "Base Salary" },
{ field: "regular_gross_paid", label: "Regular Gross Paid" },
{ field: "total_ot_paid", label: "Total OT Paid" },
];
// Each toolbar option resolves a sizing config per column, plus a short hint
// shown beside the dropdown. Most modes size every column the same way; the
// clamp mode picks a different config per field to cap one column and floor
// another.
type ColSize = (field: string) => ColAutoSizeConfig;
const SIZE_MODES: Record<string, { colSize: ColSize; hint: string }> = {
"Fit to content": {
colSize: () => ({ strategy: "max-cell" }),
hint: "Each column widens to fit its widest cell; the fit holds as you scroll.",
},
"Clamp min / max": {
colSize: (field) => {
if (field === "title_description") return { strategy: "clamped-width", maxWidthInPx: 160 };
if (field === "base_salary") return { strategy: "clamped-width", minWidthInPx: 240 };
return { strategy: "max-cell" };
},
hint: "Title is capped at 160px, Base Salary is held to at least 240px, the rest fit their content.",
},
"Share equally": {
colSize: () => ({ strategy: "static", width: 1, unit: "fr" }),
hint: "Columns split the grid width equally - no horizontal scroll.",
},
"Static 120px": {
colSize: () => ({ strategy: "static", width: 120, unit: "px" }),
hint: "Every column takes a fixed 120px slice of the grid width.",
},
};
const DEFAULT_MODE = "Fit to content";
const GRID_HEIGHT = 420;
export function mount(el: HTMLElement, ctx: SampleContext): () => void {
const gridMount = createGridMount(el, GRID_HEIGHT);
const grid = new Grid({}, gridMount, "flat");
const disposeTheme = syncGridTheme(grid);
let disposed = false;
let rows: SampleRow[] = [];
// Rebuild the table with the chosen size applied to every column, and update
// the hint under the dropdown to describe what to expect.
const render = (mode: string): void => {
hint.textContent = SIZE_MODES[mode].hint;
grid.data = new FlattenedDataViewModel({
data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
columnFacets: [COLUMNS.map((column) => column.label)],
totalRows: rows.length,
options: {
vTrackDefs: COLUMNS.map((column) => ({ colSize: SIZE_MODES[mode].colSize(column.field) })),
facetDefs: { row: [], col: [{ text: "" }], axis: "col" },
},
});
grid.draw();
};
ctx.loadDataset("payroll").then((loaded) => {
if (disposed) return;
rows = loaded;
render(DEFAULT_MODE);
});
const toolbar = createToolbar();
const select = toolbarSelect(Object.keys(SIZE_MODES), DEFAULT_MODE);
// A one-line, muted description of the selected mode, sitting in the toolbar's
// empty space to the right of the dropdown.
const hint = document.createElement("span");
hint.style.cssText =
"font-size:12px;align-self:center;color:color-mix(in srgb, currentColor 60%, transparent);";
hint.textContent = SIZE_MODES[DEFAULT_MODE].hint;
toolbar.append(select, hint);
el.append(toolbar, gridMount);
select.addEventListener("change", () => render(select.value));
return () => {
disposed = true;
disposeTheme();
el.removeChild(toolbar);
el.removeChild(gridMount);
};
}Row density
With plain text columns (no cell renderer), the grid measures each row from its
rendered content plus cell padding - it never assumes a fixed height. So the way
to make the table more compact or more spacious is to change the vertical
padding, the --cell-padding-y theme token, on the container the tracks render
onto. On the next draw the grid re-measures and the whole table tightens or
relaxes.
grid.trackSurfaceContainer.style.setProperty("--cell-padding-y", "3"); // compactBecause the row height is derived from content, this keeps every row tall enough
for its text at any density. If you instead need to pin a column to an exact
pixel height, cellHeight on a vTrackDef does that - but only alongside a
custom renderer, since the renderer owns how the cell fills that height. We cover
that in the cell renderer section below.
Try it below: switch between compact, comfortable, and spacious in the toolbar.
Source
import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { createToolbar, toolbarSelect } from "../../runtime/toolbar";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow } from "../../types";
const COLUMNS = [
{ field: "agency_name", label: "Agency" },
{ field: "title_description", label: "Title" },
{ field: "base_salary", label: "Base Salary" },
{ field: "regular_gross_paid", label: "Regular Gross Paid" },
{ field: "total_ot_paid", label: "Total OT Paid" },
];
// Static fractional track widths: the first two columns take twice the share of
// the remaining three, so the table fills the grid width with no horizontal scroll.
const COL_FR = [2, 2, 1, 1, 1];
// With no custom renderer the grid measures each row from its rendered text
// plus cell padding, so row height is driven by the `--cell-padding-y` theme
// token. Each option is a vertical padding in pixels; dialing it up or down
// makes the whole table breathe.
const DENSITIES: Record<string, number> = {
Compact: 3,
Comfortable: 7,
Spacious: 16,
};
const DEFAULT_DENSITY = "Comfortable";
const GRID_HEIGHT = 420;
export function mount(el: HTMLElement, ctx: SampleContext): () => void {
const gridMount = createGridMount(el, GRID_HEIGHT);
const grid = new Grid({}, gridMount, "flat");
const disposeTheme = syncGridTheme(grid);
let disposed = false;
let rows: SampleRow[] = [];
const render = (choice: string): void => {
// Override the theme's vertical padding on the container the tracks render
// onto. The grid re-measures row height on the next draw, so the table
// tightens or relaxes to match.
grid.trackSurfaceContainer.style.setProperty("--cell-padding-y", String(DENSITIES[choice]));
grid.data = new FlattenedDataViewModel({
data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
columnFacets: [COLUMNS.map((column) => column.label)],
totalRows: rows.length,
options: {
vTrackDefs: COLUMNS.map((_, i) => ({ colSize: { strategy: "static", width: COL_FR[i], unit: "fr" } })),
facetDefs: { row: [], col: [{ text: "" }], axis: "col" },
},
});
grid.draw();
};
ctx.loadDataset("payroll").then((loaded) => {
if (disposed) return;
rows = loaded;
render(DEFAULT_DENSITY);
});
const toolbar = createToolbar();
const select = toolbarSelect(Object.keys(DENSITIES), DEFAULT_DENSITY);
toolbar.append(select);
el.append(toolbar, gridMount);
select.addEventListener("change", () => render(select.value));
return () => {
disposed = true;
disposeTheme();
el.removeChild(toolbar);
el.removeChild(gridMount);
};
}Header content
A header renderer can flank the column label with extra content: a marker on the left and a badge on the right, with the label in the middle. This is a property of the header, not of the column's cells. The affixes are ordinary DOM, so they can carry their own behaviour - here the left dot anchors a hover tooltip.
facetDefs: {
row: [],
col: [{
text: "",
trackRenderer: (label) => ({
left: marker(), // shown before the label - anchors the tooltip
content: label, // the label itself
right: kindBadge(), // shown after the label
}),
}],
axis: "col",
},The dot owns the tooltip. On mouseenter it opens a custom-HTML card - not a
plain title attribute - positioned with fixed coordinates just below the dot so
the grid's scroll container never crops it:
dot.addEventListener("mouseenter", () => {
const r = dot.getBoundingClientRect();
card.style.top = `${r.bottom + 8}px`; // open downward from the dot
card.style.left = `${r.left + r.width / 2 - 16}px`; // caret lands under the dot
document.body.appendChild(card);
});Try it below: each header shows a leading marker and a trailing badge naming the kind of data in the column. Hover the dot to open a card describing that column.
Source
import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import type { FacetCellRenderer } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow } from "../../types";
// `note` is a short, header-specific blurb shown in the hover tooltip that the
// left dot anchors.
const COLUMNS = [
{ field: "agency_name", label: "Agency", numeric: false, note: "The City agency that employs the worker - every row is payroll under one of these." },
{ field: "title_description", label: "Title", numeric: false, note: "Civil-service title describing the role the worker is paid for." },
{ field: "base_salary", label: "Base Salary", numeric: true, note: "Annual base pay set for the title, before any overtime or other extras." },
{ field: "regular_gross_paid", label: "Regular Gross Paid", numeric: true, note: "Regular wages actually paid out across the fiscal year." },
{ field: "total_ot_paid", label: "Total OT Paid", numeric: true, note: "Overtime compensation paid on top of the regular wages." },
];
const GRID_HEIGHT = 420;
// Static fractional track widths: the first two columns take twice the share of
// the remaining three, so the table fills the grid width with no horizontal scroll.
const COL_FR = [2, 2, 1, 1, 1];
// A header renderer can place content on three sides of the label: a marker on
// the left, the text in the middle, and a badge on the right. The left dot is
// also the tooltip anchor - hovering it opens a custom-HTML card below the dot.
function headerContent(tooltip: Tooltip): FacetCellRenderer {
return (label, _dataCtx, ctx) => {
const column = COLUMNS.find((c) => c.label === label);
const dot = marker(tooltip, label, column?.note ?? "");
// Grow the dot whenever the pointer is anywhere over its header cell, not
// only when it is directly over the dot.
ctx.cell.addEventListener("mouseenter", () => (dot.style.transform = "scale(1.6)"));
ctx.cell.addEventListener("mouseleave", () => (dot.style.transform = "scale(1)"));
return {
left: dot,
content: label,
right: kindBadge(column?.numeric ? "#" : "Aa"),
};
};
}
export function mount(el: HTMLElement, ctx: SampleContext): () => void {
const gridMount = createGridMount(el, GRID_HEIGHT);
const grid = new Grid({}, gridMount, "flat");
const disposeTheme = syncGridTheme(grid);
const tooltip = createTooltip();
let disposed = false;
el.append(gridMount);
ctx.loadDataset("payroll").then((rows: SampleRow[]) => {
if (disposed) return;
grid.data = new FlattenedDataViewModel({
data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
columnFacets: [COLUMNS.map((column) => column.label)],
totalRows: rows.length,
options: {
vTrackDefs: COLUMNS.map((_, i) => ({ colSize: { strategy: "static", width: COL_FR[i], unit: "fr" } })),
facetDefs: {
row: [],
col: [{ text: "", trackRenderer: headerContent(tooltip) }],
axis: "col",
},
},
});
grid.draw();
});
return () => {
disposed = true;
disposeTheme();
tooltip.destroy();
el.removeChild(gridMount);
};
}
/* ===================================== utils ===================================== */
interface Tooltip {
attach(anchor: HTMLElement, html: string): void;
destroy(): void;
}
// A single floating tooltip reused across every dot. It is appended to the body
// so the grid's scroll container can never crop it, and positioned with fixed
// coordinates just below whichever dot is hovered.
function createTooltip(): Tooltip {
let tip: HTMLElement | null = null;
const hide = (): void => {
tip?.remove();
tip = null;
};
const show = (anchor: HTMLElement, html: string): void => {
hide();
tip = document.createElement("div");
tip.innerHTML = html;
tip.style.cssText =
"position:fixed;z-index:1000;max-width:240px;padding:8px 10px;border-radius:8px;" +
"font-size:12px;line-height:1.45;pointer-events:none;" +
"color:color-mix(in srgb, currentColor 88%, transparent);" +
"background:color-mix(in srgb, currentColor 12%, transparent);" +
"border:1px solid color-mix(in srgb, currentColor 22%, transparent);" +
"box-shadow:0 8px 24px color-mix(in srgb, currentColor 22%, transparent);" +
"backdrop-filter:blur(10px);";
// A little caret near the card's left edge, pointing up at the dot.
const caret = document.createElement("div");
caret.style.cssText =
"position:absolute;top:-5px;left:12px;width:9px;height:9px;transform:rotate(45deg);" +
"background:color-mix(in srgb, currentColor 12%, transparent);" +
"border-top:1px solid color-mix(in srgb, currentColor 22%, transparent);" +
"border-left:1px solid color-mix(in srgb, currentColor 22%, transparent);";
tip.appendChild(caret);
document.body.appendChild(tip);
// Open downward, with the caret (12px in from the card's left) landing under
// the centre of the dot.
const r = anchor.getBoundingClientRect();
tip.style.top = `${r.bottom + 8}px`;
tip.style.left = `${r.left + r.width / 2 - 16}px`;
};
return {
attach(anchor, html) {
anchor.addEventListener("mouseenter", () => show(anchor, html));
anchor.addEventListener("mouseleave", hide);
},
destroy: hide,
};
}
// A small dot that sits to the left of every column label and anchors the hover
// tooltip built from the column's header name and note.
function marker(tooltip: Tooltip, label: string, note: string): HTMLElement {
const dot = document.createElement("span");
dot.textContent = "●";
dot.style.cssText =
"display:inline-block;font-size:8px;cursor:help;transition:transform 120ms ease;" +
"color:color-mix(in srgb, currentColor 40%, transparent);";
tooltip.attach(dot, tooltipHtml(label, note));
return dot;
}
// Custom HTML for the tooltip body: the header name in bold over its description.
function tooltipHtml(label: string, note: string): string {
return (
`<div style="font-weight:600;margin-bottom:3px;">${label}</div>` +
`<div style="opacity:0.75;">${note}</div>`
);
}
// A subtle pill that sits to the right of the label.
function kindBadge(text: string): HTMLElement {
const badge = document.createElement("span");
badge.textContent = text;
badge.style.cssText =
"font-size:10px;line-height:1;padding:2px 4px;border-radius:4px;" +
"border:1px solid color-mix(in srgb, currentColor 20%, transparent);" +
"color:color-mix(in srgb, currentColor 65%, transparent);";
return badge;
}Cell renderer
A cell renderer takes over how a column's values are drawn. It returns text or DOM for a single cell - so a column can render a bar, a pill, or anything else instead of plain text. Unlike a header renderer, a cell has no left/right slots; the renderer lays out the whole cell itself. A custom-rendered cell also loses the grid's default padding and alignment, so the renderer restores whatever it needs.
A valueFormatter runs first and turns the raw value into a display string; the
renderer then receives that string as its value, while the untouched number
stays on dataCtx.rawValue (the bar reads it for its width). Here every numeric
column formats as currency, and the deepest custom renderer decides the layout.
const currency = (value) =>
typeof value === "number" ? value.toLocaleString("en-US", { style: "currency", currency: "USD" }) : "";
const numberCell = (value, _dataCtx, ctx) => {
// set properties individually - `cssText` would wipe the grid-area the layout
// sets on this same element
ctx.container.style.display = "flex";
ctx.container.style.justifyContent = "flex-end";
return value; // already the formatted currency string
};
vTrackDefs: columns.map((column) => ({
...(column.numeric
? { valueFormatter: currency, renderer: column.field === "base_salary" ? salaryBar : numberCell }
: {}),
})),Try it below: Base Salary draws each value as a proportional bar with the amount,
the other numeric columns right-align their values, and all of them are formatted
as $-prefixed locale numbers.
Source
import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import type { CellRenderer, ValueFormatter } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow, SampleValue } from "../../types";
const COLUMNS = [
{ field: "agency_name", label: "Agency", numeric: false },
{ field: "title_description", label: "Title", numeric: false },
{ field: "base_salary", label: "Base Salary", numeric: true },
{ field: "regular_gross_paid", label: "Regular Gross Paid", numeric: true },
{ field: "total_ot_paid", label: "Total OT Paid", numeric: true },
];
// The one column that draws its own visual instead of plain text.
const BAR_FIELD = "base_salary";
// Static fractional track widths: the first two columns take twice the share of
// the remaining three, so the table fills the grid width with no horizontal scroll.
const COL_FR = [2, 2, 1, 1, 1];
const GRID_HEIGHT = 420;
// Match the theme's cell padding so custom-rendered cells (which the grid strips
// padding from) line up with the built-in text cells.
const CELL_PADDING = "calc(var(--cell-padding-y) * 1px) calc(var(--cell-padding-x) * 1px)";
// Locale-aware currency: turns a raw number into e.g. `$107,789`. Shared by every
// numeric column as its valueFormatter.
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const currency: ValueFormatter<SampleValue> = (value) => (typeof value === "number" ? money.format(value) : "");
export function mount(el: HTMLElement, ctx: SampleContext): () => void {
const gridMount = createGridMount(el, GRID_HEIGHT);
const grid = new Grid({}, gridMount, "flat");
const disposeTheme = syncGridTheme(grid);
let disposed = false;
el.append(gridMount);
ctx.loadDataset("payroll").then((rows: SampleRow[]) => {
if (disposed) return;
const max = Math.max(...rows.map((row) => Number(row[BAR_FIELD]) || 0));
const bar = salaryBar(max);
grid.data = new FlattenedDataViewModel({
data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
columnFacets: [COLUMNS.map((column) => column.label)],
totalRows: rows.length,
options: {
// Every numeric column formats its value as currency; Base Salary draws a
// proportional bar, the other numbers render as right-aligned text. The
// text columns fall back to the built-in left-aligned rendering.
vTrackDefs: COLUMNS.map((column, i) => ({
colSize: { strategy: "static", width: COL_FR[i], unit: "fr" },
...(column.numeric
? { valueFormatter: currency, renderer: column.field === BAR_FIELD ? bar : numberCell }
: {}),
})),
facetDefs: { row: [], col: [{ text: "" }], axis: "col" },
},
});
grid.draw();
});
return () => {
disposed = true;
disposeTheme();
el.removeChild(gridMount);
};
}
/* ===================================== utils ===================================== */
// A cell renderer draws a single cell. It hands back DOM (or a string); unlike a
// header renderer it has no left/right slots, so this one lays out its own bar
// and amount inside the cell. The bar width comes from the raw value, while the
// label shows the value already run through the currency valueFormatter.
// Set container styles property-by-property, never via `cssText` - the grid puts
// the cell's grid-area inline on this same element and `cssText` would wipe it
// (see QUIRKS.md).
function salaryBar(max: number): CellRenderer<SampleValue> {
return (value, dataCtx, ctx) => {
const style = ctx.container.style;
style.display = "flex";
style.alignItems = "center";
style.gap = "8px";
style.overflow = "hidden";
style.padding = CELL_PADDING;
const amount = typeof dataCtx.rawValue === "number" ? dataCtx.rawValue : 0;
const pct = max > 0 ? Math.round((amount / max) * 100) : 0;
const track = document.createElement("div");
track.style.cssText =
"flex:1;height:8px;border-radius:4px;overflow:hidden;" +
"background:color-mix(in srgb, currentColor 12%, transparent);";
const fill = document.createElement("div");
fill.style.cssText = `height:100%;width:${pct}%;background:#6366f1;`;
track.appendChild(fill);
const label = document.createElement("span");
label.style.cssText = "min-width:72px;text-align:right;font-variant-numeric:tabular-nums;";
label.textContent = amount ? String(value) : "";
return [track, label];
};
}
// Right-aligns a number cell. Custom-rendered cells lose the grid's default
// padding and alignment, so this restores both around the formatted value. Styles
// are set property-by-property, never via `cssText` - that would wipe the cell's
// grid-area (see QUIRKS.md).
const numberCell: CellRenderer<SampleValue> = (value, _dataCtx, ctx) => {
const style = ctx.container.style;
style.display = "flex";
style.justifyContent = "flex-end";
style.alignItems = "center";
style.overflow = "hidden";
style.padding = CELL_PADDING;
style.fontVariantNumeric = "tabular-nums";
return value == null ? "" : String(value);
};