Skip to content

Core Features

This page documents built-in grid features that don’t require plugins — the interactive playground, column configuration, data formatting, styling, loading states, events, methods, and more.


Experiment with the grid’s core options in real time. Adjust the row count, toggle columns, change the fit mode, and enable or disable sortable/resizable columns.

Row countNumber of rows to generate
ColumnsVisible columns
Fit modeColumn sizing strategy
SortableEnable column sorting
ResizableEnable column resizing

The grid implements ARIA grid keyboard patterns out of the box — no configuration required:

KeyAction
Move between cells
Home / EndJump to first/last cell in row
Ctrl + Home / Ctrl + EndJump to first/last cell in grid
PgUp / PgDnScroll by viewport height
↵ EnterStart editing (with EditingPlugin)
EscCancel editing
⇥ Tab / ⇧ Shift + ⇥ TabMove to next/previous editable cell

For the full keyboard shortcut reference, see the Accessibility guide.

The grid fully supports RTL languages like Hebrew, Arabic, and Persian. Set dir="rtl" on the grid or any ancestor element — keyboard navigation, column pinning, and layout all adapt automatically.

<tbw-grid dir="rtl"></tbw-grid>
RTLEnable right-to-left layout

Logical column pinning: Use pinned: 'start' and pinned: 'end' instead of 'left'/'right' for direction-independent pinning. See the Pinned Columns plugin for details.


Zero-config data display — Just pass your data and the grid figures out the rest.

When you provide rows without defining columns, the grid automatically:

  • Detects fields from the first row’s property names
  • Infers data types (string, number, boolean, date) from actual values
  • Generates human-readable headers from field names (firstNameFirst Name)
  • Applies appropriate sorting and formatting for each type
grid.rows = myData; // That's it!

Pass data without defining columns — the grid infers everything automatically.

Merge mode — infer everything, customize one column

Section titled “Merge mode — infer everything, customize one column”

By default (columnInference: 'auto'), inference is all-or-nothing: the moment you declare a single column, the grid renders only that column and skips inference entirely.

Opt into columnInference: 'merge' for a low-config workflow: the grid always infers the full column set from your data, then overlays any explicitly provided columns matched by field. Feed it data and it renders everything; if you disagree with how one column is rendered, add config for just that column.

grid.columnInference = 'merge';
grid.rows = employees; // every field renders, in data-key order
// Customize only the salary column — the rest stay inferred:
grid.columns = [{ field: 'salary', type: 'number', header: 'Salary (USD)' }];

Behaviour in merge mode:

  • All data fields render in data-key order (from the first row), auto-typed.
  • A provided column overlays only its own field (your config wins; inferred values such as header/type fill the gaps) and keeps its data position.
  • A provided column for a field absent from the data is appended at the end as a computed column (e.g. an actions column).
  • To hide a column, omit its key from the row objects (control via the data shape).

Set it via the columnInference prop, the column-inference="merge" attribute, or gridConfig.columnInference. The default 'auto' preserves the classic “declare a subset → show only that subset” behaviour.

Declarative configuration — Define columns in HTML instead of JavaScript.

Use <tbw-grid-column> elements (or framework wrapper components) to declaratively define columns directly in your markup:

<tbw-grid>
<tbw-grid-column field="id" header="ID" width="80"></tbw-grid-column>
<tbw-grid-column field="name" header="Full Name" sortable></tbw-grid-column>
<tbw-grid-column field="email" header="Email Address"></tbw-grid-column>
</tbw-grid>

This approach is ideal for:

  • Static layouts where columns don’t change at runtime
  • Server-rendered pages where HTML is generated on the server
  • Template-driven frameworks like Angular or Vue that prefer declarative syntax
  • Quick prototyping without writing JavaScript

Define columns declaratively in HTML using <tbw-grid-column> elements. See Plain JavaScript (No Build Step) for a full zero-JS setup with template rendering and inline editors.

Initial column ordering with the order attribute

Section titled “Initial column ordering with the order attribute”

Use the order attribute to control the initial position of columns when they are first rendered. This is useful for reordering columns declaratively without JavaScript:

<tbw-grid>
<tbw-grid-column field="id" header="ID" order="2"></tbw-grid-column>
<tbw-grid-column field="name" header="Full Name" order="0"></tbw-grid-column>
<tbw-grid-column field="email" header="Email Address" order="1"></tbw-grid-column>
</tbw-grid>

The columns will appear in order: name, email, id.

Columns without an order attribute keep their relative order; columns with order are inserted at their target indices. The order attribute only affects the initial render; user interactions (drag-to-reorder via the Reorder plugin) take precedence after that. Use resetColumnOrder() to restore the order-attribute positioning.

Combining with columnInference: 'merge' — The order attribute is most useful in merge mode. With inference enabled, the grid automatically displays all data fields, and you can use light-DOM <tbw-grid-column> elements to customize only the columns you care about — adding a custom header, overriding the type, adjusting width, and positioning via order, all without declaring the entire column set.

The grid is configured through the gridConfig property (or individual shorthand properties). GridConfig is the full, type-checked reference — every option, its exact type and its default live there. The options below are the ones you will reach for first:

PropertyTypeDescription
columnsColumnConfig[]Column definitions
rowsany[]Row data array (top-level grid prop, not on GridConfig)
getRowId(row) => stringUnique row identity function
fitModeFitModeHow columns fill available width ('stretch' or 'fixed')
columnInferenceColumnInferenceModeHow inference combines with provided columns ('auto' default, or 'merge')
rowHeightnumber | (row, index) => number | undefinedFixed or variable row heights
typeDefaultsRecord<string, TypeDefault>Default column config per column type
pluginsGridPlugin[]Plugin instances
featuresPartial<FeatureConfig>Declarative feature config (alternative to plugins)

The remainder of GridConfig covers grid-wide toggles (sortable, resizable), initial state (initialSort, columnState), presentation overrides (icons, animation, rowClass, loadingRenderer, emptyRenderer, emptyOverlay), accessibility (a11y, gridAriaLabel, gridAriaDescribedBy) and the low-level sortHandler escape hatch.

Precedence (low → high):

  1. gridConfig prop (base)
  2. Light DOM elements (declarative)
  3. columns prop (direct array)
  4. Inferred columns (auto-detected from first row)
  5. Individual props (fitMode) — highest

In columnInference: 'merge' mode the order differs: the grid infers the full column set first, then overlays the merged provided columns by field (provided wins, in data-key order).

Some columns exist to support grid behaviour rather than to display user data — a row-action menu, a status indicator, a row number, the selection checkbox the grid injects for you. Mark any column with utility: true and the grid treats it as a system column: rendered normally, but excluded from chooser, reorder, print, export, clipboard, and selection.

{
field: '__actions',
header: '',
width: 80,
utility: true, // ← marks this as a system column
resizable: false,
sortable: false,
filterable: false,
viewRenderer: ({ row }) => createActionsButton(row),
}

What utility: true does:

SurfaceBehaviour
Visibility panelNot listed — users cannot toggle it on/off
Column reorderLocked in place
PrintHidden by PrintPlugin (override with printHidden: false)
Clipboard copySkipped by ClipboardPlugin
Export (CSV/JSON/XLSX)Skipped by ExportPlugin
Range / row selectionClick does not extend selection
Filter UINo filter button, no filter model entry
Cell renderingRendered normally — your renderer runs

Naming convention: Prefix the field with __ (e.g. __actions, __status) so it cannot collide with a real data field.

Built-in system columns the grid synthesises automatically use the same flag: SelectionPlugin checkbox (__tbw_checkbox), MasterDetailPlugin / TreePlugin / GroupingRowsPlugin expander (__tbw_expander), RowDragDropPlugin drag handle.

Related flags when you want finer control: lockPosition (reorder only), lockVisible (chooser only), printHidden (print only), hidden (entirely hidden).


A valueAccessor is the single source of truth for a column’s value. Think of it as answering one question for the grid — “what is this cell’s value?” — as a function of the whole row instead of a single property.

By default the grid reads row[field]. field may be a dotted path into a nested structure like deal.capture.instrument, resolved with no extra config.

When you supply a valueAccessor, every feature that needs a cell’s value reads through it — not just the renderer. That covers filtering, sorting, aggregation (sum / avg / …, including group-row totals), copy-to-clipboard, CSV/Excel export, and the default display. You write the value logic once and the whole grid stays consistent.

Rows are deeply nested objects. Columns bind straight to dotted paths (deal.capture.instrument) — sorting, filtering and export all work with no valueAccessor. The Total column is the exception: it's computed from two nested numbers, so it uses avalueAccessor.

When do you actually need one? Two situations:

  • The value isn’t naturally in the dataset — it has to be computed or derived: multiplied from sibling fields (qty * price), looked up inside a nested array, joined from several properties (firstName + lastName), or pulled from somewhere outside the row entirely.
  • You want to transform the stored value before any feature sees it — normalize, coerce, round, or map a raw code to a human label — so that filtering, sorting, and export all operate on the cleaned-up value rather than the raw one.

You could fake the visual result with a renderer or format function alone — but those only change how the cell looks. Everything that reasons about the cell’s value (filter matches, sort order, group keys, aggregates, clipboard, exports) would still see undefined or the raw row[field]. A valueAccessor fixes the value at the source so every consumer agrees; renderer / format then layer presentation on top.

grid.columns = [
// Plain nested read — just use a dotted `field`, no accessor needed.
{ field: 'deal.capture.instrument', header: 'Instrument' },
// Computed from sibling fields
{
field: 'total',
header: 'Total',
valueAccessor: ({ row }) => row.qty * row.price,
},
// Pluck from a nested array
{
field: 'lastShipmentDate',
header: 'Last Shipment',
valueAccessor: ({ row }) => row.shipments?.find((s) => s.kind === 'BL')?.date,
format: (v) => (v ? new Date(v).toLocaleDateString() : ''),
},
// Normalize / coerce
{
field: 'name',
valueAccessor: ({ row }) => `${row.firstName} ${row.lastName}`.trim(),
},
];

The accessor receives { row, column, rowIndex } and returns the column’s typed value. Use it whenever the cell value isn’t a plain field read.

For each operation, the grid looks for a column-level override first, then falls back to the accessor, then to the field:

OperationOrder
Sort comparisonsortComparatorvalueAccessorrow[field]
Filter valuefilterValuevalueAccessorrow[field]
Aggregations (sum, avg, min, max, first, last)valueAccessorrow[field]
Copy / export (CSV, Excel)valueAccessorrow[field] (then format if present)
Display (format / renderer)valueAccessorrow[field]

This means you write the lookup logic once and every consumer stays consistent.

Row grouping is the exception: group keys come from the groupOn callback you supply, not from valueAccessor. Only the aggregated values shown on group rows go through the accessor.

Accessor results are cached per (row, column.field) in a WeakMap keyed on the row object — so an expensive accessor (e.g. array.find) runs once per row regardless of how many features read it. Primitive rows bypass the cache.

The cache is invalidated automatically when:

  • A row reference changes (immutable updates — recommended pattern).
  • You call RowManager.updateRow / updateRows / applyTransaction (in-place edits).
  • The Editing plugin commits a value.

If you mutate row data outside of those paths, call invalidateAccessorCache(row?, field?) manually:

import { invalidateAccessorCache } from '@toolbox-web/grid';
row.shipments.push(newShipment);
invalidateAccessorCache(row, 'lastShipmentDate'); // narrow scope
// or invalidateAccessorCache(row); // all fields on this row
// or invalidateAccessorCache(); // entire cache

Accessors are read-only. Cells driven by a valueAccessor cannot currently be written back through the Editing plugin (a matching valueSetter API is planned). For now, gate them with editable: false or omit editable.


Transform how values are displayed — Formatters convert raw data values into user-friendly text.

A formatter is a function that receives the cell value and returns a display string:

grid.columns = [
// Currency
{ field: 'salary', format: (v) => `$${v.toLocaleString()}` },
// Date
{ field: 'hireDate', format: (v) => new Date(v).toLocaleDateString() },
// Percentage
{ field: 'progress', format: (v) => `${(v * 100).toFixed(1)}%` },
// Prefix
{ field: 'id', format: (v) => `#${v}` },
];

Style entire rows based on data using the rowClass callback:

grid.gridConfig = {
rowClass: (row) => (row.status === 'inactive' ? 'row-inactive' : ''),
};

Then define the CSS class in your stylesheet:

/* Dim with a surface tint rather than `opacity` — `opacity` fades text as
well as chrome and quickly drops it below the 4.5:1 WCAG 1.4.3 minimum. */
.row-inactive { background: light-dark(#f1f2f4, #26282d); font-style: italic; }

Style individual cells based on their value using cellClass on a column:

grid.columns = [
{
field: 'score',
cellClass: (value) => {
if (value >= 90) return 'cell-success';
if (value < 50) return 'cell-danger';
return '';
},
},
];

Use rowClass and cellClass to apply conditional styles based on data.

Full control over cell content — Renderers let you create custom HTML elements for cells.

While formatters return plain text, renderers return DOM elements. Use renderers when you need:

  • Custom components: Checkboxes, badges, progress bars, buttons
  • Interactive elements: Links, icons, action buttons
  • Rich formatting: Multiple elements, images, complex layouts
grid.columns = [
{
field: 'status',
header: 'Status',
renderer: (ctx) => {
const badge = document.createElement('span');
badge.className = `badge badge-${ctx.value}`;
badge.textContent = ctx.value;
return badge;
},
},
{
field: 'active',
header: 'Active',
renderer: (ctx) => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = !!ctx.value;
checkbox.disabled = true;
// A disabled control still needs an accessible name.
checkbox.setAttribute('aria-label', 'Active');
return checkbox;
},
},
];

The renderer receives a CellRenderContext — the cell value, the row object, the field name, and the column config.

Renderers give full control over cell content — create badges, checkboxes, progress bars, and more.

You can also define a cell renderer declaratively in your template — favouring markup over an imperative JavaScript callback. It is not the highest-performance path, but it has its place when you want more configuration in the template and less in JavaScript (a step toward setting up a grid with as little JS as possible). Every stack supports it: a light-DOM <tbw-grid-column-view> element in vanilla (use {{ value }} to interpolate the cell value, and {{ row.field }} for other row fields), a renderer prop in React, a #cell slot in Vue, and the *tbwRenderer directive in Angular:

<tbw-grid column-inference="merge">
<tbw-grid-column field="status">
<tbw-grid-column-view>
<span class="badge badge-{{ value }}">{{ value }}</span>
</tbw-grid-column-view>
</tbw-grid-column>
</tbw-grid>

The companion light-DOM templates are <tbw-grid-column-editor> (custom editor, requires the Editing plugin) and <tbw-grid-column-header> (custom header cell). See the API reference for the full list.

Renderers run with full DOM access. The grid does not sandbox what you do inside one, so user-supplied data must be inserted safely. The hazard is innerHTML:

// ✅ Safe — textContent escapes HTML automatically
renderer: (ctx) => {
const span = document.createElement('span');
span.textContent = ctx.value;
return span;
};
// ❌ XSS vulnerability — user input rendered as HTML
renderer: (ctx) => {
const div = document.createElement('div');
div.innerHTML = ctx.value; // If value contains <script>, it executes
return div;
};

If you genuinely need to inject HTML (e.g. rendering a server-trusted markdown snippet), sanitize first with a library like DOMPurify before assigning to innerHTML.

Now that you’ve seen both, here’s a quick reference for picking the right one:

AspectFormatter (format)Renderer (renderer)
ReturnsStringDOM element
Use forCurrency, dates, percentages, text formattingCheckboxes, badges, buttons, links, images
PerformanceFaster (text only)Slower (DOM creation)
InteractivityNone (display only)Full (event listeners, components)

Rule of thumb: If you only need to change how a value looks, use a format function. If you need interactive elements or custom HTML structure, use a renderer.

The grid provides a built-in row animation API for highlighting changes, insertions, and removals with visual feedback.

MethodDescriptionCSS Variable
animateRow(i, 'change')Flash highlight for data changes--tbw-row-change-duration (500ms)
insertRow(i, row)Slide-in animation for new rows--tbw-row-insert-duration (300ms)
removeRow(i)Fade-out animation for removed rows--tbw-row-remove-duration (200ms)
applyTransaction(tx)Animates add/update/remove in one passAll of the above
// Highlight a row after updating
grid.rows[5].status = 'updated';
grid.animateRow(5, 'change');
// Animate by row ID (stable when sorted/filtered)
grid.animateRowById(data.id, 'change');
// Animate multiple rows at once
grid.animateRows([0, 2, 5], 'change');

applyTransaction() batches add, update, and remove operations into a single render cycle instead of re-assigning the whole dataset. applyTransactionAsync() additionally merges every call made within one animation frame.

interface RowTransaction<T> {
add?: T[]; // Appended to the end
update?: { id: string; changes: Partial<T> }[]; // In-place mutation by row ID
remove?: { id: string }[]; // Removed by row ID
}

Operations apply in the order removes → updates → adds, so updates never target a row that is about to be removed and new rows never collide with existing IDs. Matching by ID requires a getRowId function on the grid config.

ScenarioMethodAnimations
User action, moderate stream (< 10 msg/s)applyTransaction()Yes (configurable)
High-frequency ticker (100+ msg/s)applyTransactionAsync()Disabled (batched)

Both return a TransactionResult carrying the actual added, updated, and removed row objects.

Customize appearance:

tbw-grid {
--tbw-row-change-duration: 750ms;
--tbw-row-change-color: rgba(34, 197, 94, 0.25);
}

Built-in row animation API for highlighting changes, insertions, and removals.

Define defaults for all columns of a specific type. A type default accepts any ColumnConfig property — format, renderer, width, sortable, cellClass, sortComparator, and so on — plus the column properties contributed by whichever plugins you import (for example editor and editorParams from the EditingPlugin, or filterType from the FilteringPlugin).

Six properties are never inherited from a type default, because they identify or position a single column rather than describe its type: field, header, order, group, hidden and utility.

Resolution is per-property and shallow: a column keeps any value it defines itself (including falsy ones such as sortable: false or width: 0), and inherits the type-level value only where it left the property undefined or null.

grid.gridConfig = {
typeDefaults: {
currency: {
format: (value) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(value),
},
boolean: {
renderer: (ctx) => {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!ctx.value;
cb.disabled = true;
return cb;
},
},
status: {
renderer: (ctx) => {
const badge = document.createElement('span');
badge.className = `status-badge status-${ctx.value}`;
badge.textContent = ctx.value;
return badge;
},
},
// Any column property works, not just format/renderer:
percent: {
width: 110,
cellClass: 'numeric',
sortComparator: (a, b) => (a as number) - (b as number),
},
},
columns: [
{ field: 'salary', type: 'currency' },
{ field: 'active', type: 'boolean' },
{ field: 'status', type: 'status' },
{ field: 'margin', type: 'percent' },
// Column-level values always win — this one stays 220px wide.
{ field: 'growth', type: 'percent', width: 220 },
],
};

Type defaults reduce repetition when many columns share the same presentation. Column-level format or renderer overrides type defaults when both are specified.

You can also define type defaults directly in light DOM with <tbw-grid-type>.

Define type-level renderer/editor defaults declaratively with <tbw-grid-type>.

{{ typeDefault.currency }} {{ value }}
<tbw-grid id="orders-grid">
<tbw-grid-type name="currency" data-currency="USD" data-locale="en-US">
<tbw-grid-column-view>
<span class="money-cell">{{ typeDefault.currency }} {{ value }}</span>
</tbw-grid-column-view>
<tbw-grid-column-editor>
<input type="number" value="{{ value }}" />
</tbw-grid-column-editor>
</tbw-grid-type>
<tbw-grid-column field="amount" type="currency" editable></tbw-grid-column>
<tbw-grid-column field="tax" type="currency" editable></tbw-grid-column>
</tbw-grid>
<script type="module">
import '@toolbox-web/grid/features/editing';
const grid = document.getElementById('orders-grid');
grid.gridConfig = {
features: { editing: { editOn: 'click' } },
// Programmatic typeDefaults win over declarative tbw-grid-type when both exist.
// typeDefaults: { currency: { renderer: ... } }
};
</script>

Notes:

  • <tbw-grid-type> applies defaults to every column whose type matches name.
  • data-* attributes on <tbw-grid-type> are exposed in templates as typeDefault.*.
  • Programmatic gridConfig.typeDefaults entries override declarative <tbw-grid-type> entries for the same type.
  • Type-level editors require the editing feature/plugin to be enabled in your runtime/framework package. editor and editorParams only appear on the TypeDefault type once the editing plugin’s types are in scope.

Customize column header cells using headerLabelRenderer or headerRenderer.

headerLabelRenderer — Modify just the label portion of the header (sort icons and filter buttons are still managed by the grid):

{
field: 'name',
header: 'Name',
headerLabelRenderer: ({ value }) => {
const span = document.createElement('span');
span.innerHTML = `${value} <span style="color:red">*</span>`;
return span;
},
}

headerRenderer — Full control over the entire header cell. You are responsible for rendering sort icons using ctx.renderSortIcon():

{
field: 'email',
header: 'Email',
headerRenderer: (ctx) => {
const wrapper = document.createElement('div');
wrapper.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%';
const icon = document.createElement('span');
icon.textContent = '📧';
wrapper.appendChild(icon);
const label = document.createElement('span');
label.textContent = ctx.value;
label.style.flex = '1';
wrapper.appendChild(label);
const sortIcon = ctx.renderSortIcon();
if (sortIcon) wrapper.appendChild(sortIcon);
return wrapper;
},
}

The HeaderCellContext gives you the label text, the column config, the current sort/filter state, and helpers to render the built-in sort icon and filter button when you want to keep them.

Name uses headerLabelRenderer (adds a red asterisk).Email uses headerRenderer (full control — a multi-line block with a custom icon, a caption and the sort icon). A tall header shrinks the scrollable row viewport; the grid accounts for that so the last row stays reachable.


The grid tracks column state — widths, sort direction, order, and visibility. You can save, load, and reset this state for user personalization.

getColumnState() returns an array of GridColumnState objects — one entry per column capturing its field, current width, sort direction and priority, and visibility.

The column-state-change event fires whenever the user resizes, reorders, sorts, or hides/shows columns:

grid.on('column-state-change', (state) => {
localStorage.setItem('my-grid-state', JSON.stringify(state));
});

Restore a previously saved state using applyColumnState():

const saved = localStorage.getItem('my-grid-state');
if (saved) {
grid.applyColumnState(JSON.parse(saved));
}

Re-assign the original column definitions to reset to defaults:

grid.columns = [...originalColumns];

What users usually ask for is not “column state” but saved views: “give me my Q3 Review layout back”. There is no profiles feature to enable — a profile is just a snapshot you assemble from the core column-state API plus the plugins that own the rest of the layout, stored under a name.

Part of the layoutCaptured withRestored with
Order, width, visibility, sortgrid.getColumnState()grid.applyColumnState(state)
Filtersincluded in column state when filtering runs with trackColumnState: true, otherwise getFilterModel()applyColumnState, otherwise setFilterModel()
Multi-column sortmultiSort.getSortModel()multiSort.setSortModel(model)
PinningpinnedColumns.getLeftPinnedColumns() / getRightPinnedColumns()pinnedColumns.setPinPosition(field, position)
Row groupingthe group-by fields your app chose (the groupOn callback is a function and cannot be serialized) + groupingRows.getExpandedGroups()groupingRows.setGroupOn(fn, expandedKeys)
import { queryGrid } from '@toolbox-web/grid';
import type { GridColumnState } from '@toolbox-web/grid';
const grid = queryGrid('tbw-grid');
const STORE_KEY = 'employee-grid.profiles';
interface LayoutProfile {
name: string;
columns: GridColumnState;
pinned: Record<string, 'left' | 'right'>;
sort: { field: string; direction: 'asc' | 'desc' }[];
groupBy: string[];
expandedGroups: string[];
}
function captureProfile(name: string, groupBy: string[]): LayoutProfile {
const pinnedPlugin = grid.getPluginByName('pinnedColumns');
const pinned: LayoutProfile['pinned'] = {};
pinnedPlugin?.getLeftPinnedColumns().forEach((c) => (pinned[c.field] = 'left'));
pinnedPlugin?.getRightPinnedColumns().forEach((c) => (pinned[c.field] = 'right'));
return {
name,
groupBy,
columns: grid.getColumnState(),
pinned,
sort: grid.getPluginByName('multiSort')?.getSortModel() ?? [],
expandedGroups: grid.getPluginByName('groupingRows')?.getExpandedGroups() ?? [],
};
}
function applyProfile(profile: LayoutProfile): void {
grid.applyColumnState(profile.columns);
const pinnedPlugin = grid.getPluginByName('pinnedColumns');
grid.columns.forEach((c) => pinnedPlugin?.setPinPosition(c.field, profile.pinned[c.field]));
grid.getPluginByName('multiSort')?.setSortModel(profile.sort);
const grouping = grid.getPluginByName('groupingRows');
grouping?.setGroupOn(
profile.groupBy.length
? (row) => profile.groupBy.map((f) => (row as Record<string, unknown>)[f])
: undefined,
profile.expandedGroups,
);
}
// Storage is deliberately boring — swap localStorage for a `PUT /users/me/views` call
// to make profiles follow the user across machines.
function listProfiles(): Record<string, LayoutProfile> {
return JSON.parse(localStorage.getItem(STORE_KEY) ?? '{}');
}
function saveProfile(name: string, groupBy: string[] = []): void {
localStorage.setItem(
STORE_KEY,
JSON.stringify({ ...listProfiles(), [name]: captureProfile(name, groupBy) }),
);
}
function loadProfile(name: string): void {
const profile = listProfiles()[name];
if (profile) applyProfile(profile);
}


The grid supports loading indicators at three levels: grid-wide, per-row, and per-cell.

Grid-level — Shows a full overlay spinner:

grid.loading = true;
try {
grid.rows = await fetchRows();
} finally {
grid.loading = false; // in `finally`, or a rejected fetch leaves the overlay up forever
}

The loading attribute also works in HTML: <tbw-grid loading></tbw-grid>.

Row-level — Shows a spinner on a specific row (requires getRowId):

grid.setRowLoading('row-42', true);
// ... update row ...
grid.setRowLoading('row-42', false);

Cell-level — Shows a spinner on a specific cell:

grid.setCellLoading('row-42', 'status', true);
// ... update cell ...
grid.setCellLoading('row-42', 'status', false);

Query and clear:

grid.isRowLoading('row-42'); // boolean
grid.isCellLoading('row-42', 'name'); // boolean
grid.clearAllLoading(); // remove all indicators
Mode
Auto-reset (1 s)

Click ▶ Simulate to show the grid loading overlay.

Replace the default spinner with a custom element using loadingRenderer:

grid.gridConfig = {
loadingRenderer: (context) => {
const el = document.createElement('div');
el.className = 'my-spinner';
// context.size is 'large' (grid-level) or 'small' (row/cell)
el.style.width = context.size === 'large' ? '48px' : '16px';
el.style.height = el.style.width;
return el;
},
};

The LoadingContext provides a size property: 'large' for the grid-wide overlay (up to 48×48 px) and 'small' for row/cell indicators (sized to the row height).

Replace the default spinner with a custom linear progress bar. Click Toggle to switch loading state.

When the grid has no rows and is not loading, it shows a built-in message (No data to display, or No matching rows when a filter plugin hides every source row). Override the message with emptyRenderer — typically to surface error text from a failed fetch, or to provide an actionable empty state.

grid.gridConfig = {
emptyRenderer: (ctx) => {
if (loadError) return `Failed to load deals: ${loadError.message}`;
if (ctx.filteredOut) return 'No deals match the current filter.';
return 'No deals to display.';
},
};

The renderer receives an EmptyContext with:

  • sourceRowCount — the number of input rows before any plugin filtering.
  • filteredOuttrue when sourceRowCount > 0 but all rows were hidden (e.g. by the FilteringPlugin or grouping).

Return an HTMLElement for rich content (icons, buttons, multi-line layouts) or a string for plain text. Set emptyRenderer: null to suppress the overlay entirely.

The overlay mounts inside the rows container by default. Set emptyOverlay: 'grid' to cover the entire grid (including the header) instead — useful for full-page error states.

grid.gridConfig = {
emptyOverlay: 'grid',
emptyRenderer: (ctx) => /* ... */,
};

When the grid has no rows and is not loading, it shows a default "No data" message. Provide an emptyRenderer to display custom messages — e.g. error text from a failed fetch. Use the buttons below to simulate different states.


By default all rows share a fixed height (--tbw-row-height, default 28 px). You can configure per-row heights using a function.

// Fixed height for all rows
grid.gridConfig = { rowHeight: 56 };
// Per-row height function
grid.gridConfig = {
rowHeight: (row, index) => (row.hasDetails ? 80 : 40),
};

If your function returns undefined for a row, the grid auto-measures that row’s actual DOM height after rendering.

  1. The grid renders rows with an estimated height.
  2. After paint, it reads offsetHeight for each rendered row.
  3. Measured heights are cached and the position cache is rebuilt.
  4. A ResizeObserver watches for late layout shifts (font loading, lazy images) and re-measures automatically.

Measured heights are cached using:

  • getRowId / rowId — preferred, survives sort/filter changes
  • Object reference (WeakMap) — fallback when no ID is configured

Provide getRowId for best results when rows are re-sorted, filtered, or grouped.

Plugins can override row heights by implementing the getRowHeight(row, index) hook. The MasterDetailPlugin and ResponsivePlugin use this to provide expanded-row heights.

  • Fixed heights are fastest — the grid can calculate positions with pure math.
  • Function-based heights add a per-row function call during position-cache rebuilds.
  • Auto-measured heights require a DOM read pass after rendering — avoid for 10 k+ row grids unless combined with getRowId caching.
  • When mixing fixed and auto-measured rows, return a number for most rows and undefined only for rows that need measurement.

The grid dispatches standard CustomEvents for user interactions — clicks, sort changes, column resizes, and more. Plugin-specific events (selection, editing, filtering, etc.) are also available when those plugins are active.

For the complete event reference — including listening patterns per framework, cancelable events, and all plugin events — see the Events section of the API Reference.


The <tbw-grid> element exposes public methods for programmatic control — data manipulation, focus management, column state persistence, custom styles, shell control, loading indicators, row animation, and more. Use createGrid() or queryGrid() for type-safe access.

For the complete method reference — including signatures, return types, and factory functions — see the API Reference page.


  • Getting Started — Installation and setup
  • Recipes — Task-oriented how-to guides
  • Plugins — Extend with selection, filtering, editing, and more
  • Theming — CSS custom properties, dark mode, themes
  • Architecture — Render scheduler, Light DOM, design decisions
  • Plugin System — Build your own plugins