Columns
Define fields, headers, sizing, renderers, and column behavior.
Columns describe how row data is read, displayed, edited, filtered, and written back.
import { Grid, type ColumnDef, numberType } from '@witbybit/wit-grid-react';
type Person = {
id: string;
name: string;
team: string;
score: number;
};
const columns: ColumnDef<Person>[] = [
{ field: 'name', header: 'Name', width: 180 },
{ field: 'team', header: 'Team', width: 160 },
{ field: 'score', header: 'Score', width: 120, type: numberType() },
];
export function PeopleGrid({ rows }: { rows: Person[] }) {
return <Grid rows={rows} columns={columns} getRowId={(row) => row.id} />;
}Required Shape
Every column needs:
| Field | Type | Notes |
|---|---|---|
field | string | Stable data path and column identity. Dot paths are supported for nested values. |
header | string | Visible column header text. |
Use stable field values. They are used by state, events, filtering, sorting, selection ranges, clipboard, and persistence. Use colId only when the public API identity needs to differ from the row data path.
Sizing And Visibility
const columns = [
{ field: 'name', header: 'Name', width: 220, minWidth: 140, maxWidth: 360 },
{ field: 'internalNotes', header: 'Notes', hide: true },
{ field: 'status', header: 'Status', pinned: 'left' },
];width must be a positive finite number. minWidth and maxWidth constrain user resizing. pinned is an initial setting; use the grid API for runtime pin changes.
A column with flex fills the grid: flex columns grow from their width (else minWidth) to share the room the
other columns leave, by their numbers, each within its maxWidth, and follow the grid as it is resized. When the
columns are wider than the grid, a flex column keeps its width and the grid scrolls. A column the person resizes (or
columnState sizes) keeps that width and stops growing.
const columns = [
{ field: 'id', header: 'ID', width: 90 },
{ field: 'title', header: 'Title', flex: 2, minWidth: 200 },
{ field: 'notes', header: 'Notes', flex: 1, width: 160, maxWidth: 480 },
];Users hide a column by dragging its header out of the grid (the drag preview reads Hide before they let go;
Escape cancels; the last visible column stays), from the sidebar's Columns panel, or with
api.setState({ columnState: [{ field: field, hide: true }] }). Turn the drag off with dragOutHidesColumns={false} (core:
mountGridHost(api, el, { dragOutHidesColumns: false })). A hidden column keeps its filter, which keeps filtering,
and a pinned column stays pinned: shown again, it is back in its lane.
Values
By default, the grid reads row[field]. Dot paths read nested data.
const columns = [{ field: 'account.owner.name', header: 'Owner' }];Records from a flat store or a form builder often keep a dotted name as one key: { 'owner.name': 'Ann' }. Say so
with fieldPath: false, and the field is that key everywhere — the cell, sort, filter, search, export, edits, drafts,
rules, saving and the server's patches. A nested owner.name beside it is never read or written.
const columns = [{ field: 'owner.name', header: 'Owner', fieldPath: false }];A valueGetter's node.getValue(field) still reads a path; read a key field there as row['owner.name'].
Use valueGetter when display data is derived.
const columns: ColumnDef<Person>[] = [
{
field: 'displayName',
header: 'Display name',
valueGetter: ({ row }) => `${row.name} (${row.team})`,
valueGetterDependencies: ['name', 'team'],
},
];Use format when the raw value should stay unchanged, but display/export text should be formatted.
const columns: ColumnDef<Person>[] = [
{
field: 'score',
header: 'Score',
format: ({ value }) => `${Number(value).toFixed(1)} pts`,
},
];Writing Values
Use valueSetter to control edit commits.
const columns: ColumnDef<Person>[] = [
{
field: 'score',
header: 'Score',
valueSetter: ({ row, value }) => {
const next = Number(value);
if (!Number.isFinite(next) || next < 0) return false;
row.score = next;
return true;
},
},
];Return false to reject a commit. Return a promise when validation depends on asynchronous work. Call abort() for an immediate rollback.
Column Types
Wit Grid ships these types in the grid core. A type is column settings: spread one into a column
({ field: 'due', header: 'Due', type: dateType() }), and what the column sets after it wins.
| Type | Behavior |
|---|---|
checkbox | Checkbox; a press selects the cell and toggles the value. |
date, datetime | Intl date (and time) display, with a calendar popover editor. |
number, currency, percent | Intl number formats, right-aligned, with a stepper editor. |
progress, rating | Progress bar with label; stars, click to rate. |
url, email | Links; only http, https and mailto become links. |
tags | Palette-coloured chips; type in the editor to create a tag. |
The cells are DOM renderers and editors: no React work per cell, so they cost no more than plain text during
scroll. Their colours come from the grid theme (text, accent, popover tokens), so badges, chips and editor
popovers follow built-in and custom themes. Set --og-ct-accent, --og-ct-radius or --og-ct-rating on the
grid to adjust the cells alone.
A column names the kind of value it holds with type; the type supplies its renderer, editor, text (export and
copy), parsing, sorting and filter, and whatever the column sets itself wins. Types take options:
import { Grid, currencyType, comboboxType } from '@witbybit/wit-grid-react';
const columns = [
{ field: 'revenue', header: 'Revenue', type: currencyType({ currency: 'USD' }) },
{
field: 'team',
header: 'Team',
type: comboboxType([
{ value: 'fe', label: 'Frontend', group: 'Engineering' },
{ value: 'ui', label: 'UI Design', group: 'Design' },
]),
},
];
<Grid rows={rows} columns={columns} />;A kind of value of your own (money with its currency, a quantity with its unit, a total worked out from other fields)
is defined once with defineFieldType, the way the grid's own are: see Field types.
Factories: selectType, multiSelectType, comboboxType, tagsType,
personType, numberType, currencyType, percentType, dateType,
dateTimeType, progressType, ratingType, checkboxType, urlType and
emailType. Options are { value, label?, color?, icon?, description?, group?, disabled? }, where
color is a palette name (gray, red, amber, emerald, blue, violet, rose…) or any CSS colour.
A type supplies renderer, cellEditor, format and filterDef; whatever the column sets itself wins.
Dates, times and time zones
A dateType value is a calendar day, YYYY-MM-DD: the same day everywhere, never moved by a zone. A dateTimeType
value is an instant: epoch ms, a Date, or ISO text with Z or an offset. Date-times sort as instants, so
01:45+01:00 sorts before 01:15+00:00.
Name the zone date-times mean once, on the grid or on a field, and everything that shows, reads and edits them uses it:
import { createClientGrid, dateTimeType, dateType } from '@witbybit/wit-grid-react';
interface Visit {
id: string;
start: string;
shipped: string | null;
due: string;
}
const api = createClientGrid<Visit>({
rows: [],
columns: [
{ field: 'start', header: 'Start', type: dateTimeType() }, // the grid's zone
{ field: 'shipped', header: 'Shipped', type: dateTimeType({ timeZone: 'Asia/Kolkata' }) }, // its own
{ field: 'due', header: 'Due', type: dateType() }, // a day: no zone
],
getRowId: (row) => row.id,
dates: { timeZone: 'Europe/London' },
});
api.timeZoneOf('start'); // 'Europe/London'With a zone, cells show the time there, the editor shows and reads wall time there (its calendar's today is that
zone's day), and new values are written as UTC ISO (2026-06-01T09:00:00.000Z); a cell holding a Date or epoch ms
keeps that shape. Text without an offset already in a cell is read as wall time in the zone. A time typed in a skipped
hour moves forward by the change (01:30 on London's spring change is 02:30); one in a repeated hour is its earlier
occurrence. Opening and closing the editor without a change writes nothing, so a later occurrence and its precision
are kept. Without a zone, nothing changes: date-times mean the viewer's local time and are written as before.
Zones are IANA names, or 'local' for the viewer's; an unknown one throws, naming the field or dates.timeZone.
dates also takes weekStartsOn and now (the grid's clock, for tests); all of it is live (setOptions).
More cell types
| Factory | Value | Cell | Editor |
|---|---|---|---|
switchType({ onLabel, offLabel }) | boolean ('true', 1 keep shape) | On / off switch, toggles on press | Enter toggles |
segmentedType(options) | option value | All options inline; one press picks | Enter opens them as a list |
colorType({ swatches }) | #rrggbb | Swatch and hex | Palette (arrow keys), hex field, system picker, screen eyedropper |
longTextType({ lines, maxLength }) | string | One to three clamped lines, full text on hover | Textarea popover: Enter adds a line, Ctrl / ⌘ + Enter saves |
linkedRecordType(records, { loadOptions, onOpen }) | record ids | Record chips; pressing one calls onOpen | Record picker (paged from your API with loadOptions) |
cascadeType({ options | loadChildren }) | path ['in', 'ka', 'blr'] or 'in/ka/blr' | “India › Karnataka › Bengaluru” | One column per level, ← → ↑ ↓ Enter, search across paths, async levels |
dateRangeType({ presets }) | { start, end }, [start, end] or 'start/end' | “Mar 4 – 18, 2026” and its length | Two-month range calendar with presets, Apply |
sparklineType(options) | numbers (array, '1,2,3', { values }) | Line, area, bar or win / loss chart | (display only) |
timelineType(options) | { start, end }, [start, end] or 'start/end' | A bar on a time scale shared by the column | Drag to move, drag an end to resize; double-click for the calendar |
Sparklines take type, color or colorBy: 'trend', curve: 'smooth', markers (last, minmax, all),
reference (a value, 'average' or 'zero'), label (last, change), format, fixed min / max for
comparable rows, height and strokeWidth. They stretch to the column without measuring it and keep strokes
and markers crisp.
A timeline column is a Gantt column: every cell shares one time scale (range, or the column's span padded to whole
weeks, widening when a value falls outside it), with month lines and a today line. Dragging snaps to whole days and
writes once on release, in the shape the cell held. color takes a palette name, a CSS colour or (row) => colour;
editable: false and showToday: false turn those off.
cascadeType loads levels with loadChildren(path, signal) (mark leaves isLeaf: true), labels stored
paths with resolvePath(path), and searches a server with searchPaths(query, signal); changeOnSelect allows
picking a state as well as a city. Checkbox cells now toggle on Enter too.
Options from a server
selectType, comboboxType, multiSelectType, tagsType and personType
can load their options instead of listing them:
import { comboboxType } from '@witbybit/wit-grid-react';
const account = {
field: 'account',
header: 'Account',
type: comboboxType([], {
// One page of matches. Return `hasMore: false` (or a plain array) on the last page.
loadOptions: async ({ search, offset, limit, signal, rowId }) => {
const res = await fetch(`/api/accounts?q=${encodeURIComponent(search)}&offset=${offset}&limit=${limit}`, { signal });
const { items, total } = await res.json();
return { options: items.map((a) => ({ value: a.id, label: a.name, description: a.region })), hasMore: offset + limit < total, total };
},
// Labels for the values cells show before any list has loaded them (one batched call).
resolveOptions: async (ids) =>
(await fetch(`/api/accounts?ids=${ids.join(',')}`).then((r) => r.json())).map((a) => ({ value: a.id, label: a.name })),
pageSize: 50,
}),
};- The editor searches on the server after a pause in typing (
debounceMs, default 250) and fromminQueryLengthcharacters. One request is in flight at a time: a newer search waits for it, then the latest runs. Closing the editor aborts the page still loading (signal). - The next page loads when the list is scrolled near its end, or when arrowing past the last option. The list
shows a loading row, a Retry row when a page fails, and "50 of 1,204" when the loader returns
total. - Cells show a placeholder while
resolveOptionslooks a value up. Every option a page or lookup returns is remembered, so its label appears in cells, exports and filters. rowIdandcolFieldlet options depend on the row (cities of the row's country, say).- The column's filter lists the same options (loaded the same way), so filters and cells agree on labels.
The renderers and editors are also exported on their own (createSelectRenderer, createSelectEditor,
createDateEditor…) for use on a column directly: renderer: { kind: 'dom', renderer } and
cellEditor: { kind: 'dom', editor }. A DomCellEditor you write works the same way.
Renderers And Editors
Use renderer to control cell display. Use cellEditor to control edit UI.
const columns: ColumnDef<Person>[] = [
{
field: 'team',
header: 'Team',
renderer: { kind: 'react', component: TeamBadge },
cellEditor: TeamEditor,
},
];A React renderer's component gets the cell's props (CellRendererProps<Person>): row is typed by the column's row,
and value is unknown, so the component narrows it (String(value), a type check). Write it inline and its props
are typed for you: renderer: { kind: 'react', component: ({ row, value }) => <TeamBadge team={row.team} /> }.
Renderer specs support text, DOM, React, and imperative React modes. A DOM renderer's mount() and update()
receive the cell's value, node, col, state flags and the grid api — the same api React renderers get. For high-frequency cells, capabilities.scroll chooses between 'text' (stand-in text while scrolling) and 'live' (the real renderer, within a per-frame budget) — see Renderer Capabilities for the tradeoff and when to use each.
Data Entry
A grid typed into like a spreadsheet — an order's lines, a timesheet — chooses how the keys move through it. Every
behaviour is an option of editing, and each default is the grid's usual behaviour:
| Option | Does |
|---|---|
tabTo: 'editable' | Tab and Shift+Tab go to the next cell that can be edited (canEdit), skipping worked-out and locked cells. Default 'cell'. |
tabInList: 'accept' | Tab in an open list of choices takes the highlighted one and goes on, as a spreadsheet's autocomplete does. Default 'cancel'. |
newRows | Typing past the end adds a row: Enter, ↓ on the last row or Tab past its last cell add create()'s record and edit its first editable cell. |
import type { GridEditingOptions } from '@witbybit/wit-grid-react';
interface Line {
id: string;
item: string;
qty: number;
}
let next = 0;
const editing: GridEditingOptions<Line> = {
tabTo: 'editable',
tabInList: 'accept',
newRows: {
create: () => ({ id: `new-${++next}`, item: '', qty: 0 }),
// The keys that add one (default: all three), and what the change is called.
keys: ['Enter', 'ArrowDown', 'Tab'],
action: 'add line',
},
};A new row is an ordinary write, made as the user: the rules judge it, canCreate can refuse it, it is one undo step
and it saves like any other change. A new row left as it was created is taken back when focus leaves it, as if it
was never added (nothing to undo or redo, nothing saved); keepUntouched: true keeps it. The newRow command adds one
from a button, a menu or the palette (api.runCommand('newRow')), and is offered only where newRows is set.
An editor that handles Tab or Enter itself ends the edit the same way the grid's own does:
onCommit(value, 'next' | 'previous' | 'below') commits and goes on, editing the next cell. The built-in editors do;
api.commitEdit(rowId, field, value, { move }) is the same from code. The grid's own columns (the selection
checkboxes, the hierarchy) are never an edit target.
Filtering And Sorting
const columns: ColumnDef<Person>[] = [
{ field: 'name', header: 'Name' }, // filters as text
{ field: 'score', header: 'Score', filterDef: { type: 'number' } },
{ field: 'team', header: 'Team', filterDef: { type: 'select' } },
{ field: 'id', header: 'ID', filterDef: { type: 'none' } },
];Column types choose a fitting filter on their own; filterDef overrides it. See Filtering.
Option columns (selectType, comboboxType, segmentedType) sort by what the user sees: in the
order their options are listed, so a status column runs Backlog → To do → In progress → Done and a priority column
Urgent → High → Medium → Low, with values not in the list last. sortBy: 'label' sorts alphabetically by the label
instead. Neither sorts by the hidden stored value.
Any column can choose what it sorts by with sortValue: (value) => sortKey. Every client sort uses it — sorting,
grouped sorting (group rows of that column follow it too) and re-sorting after an edit, so an edited row lands
exactly where a full sort would put it.
const size: ColumnDef<Person> = { field: 'size', header: 'Size', sortValue: (v) => ['XS', 'S', 'M', 'L', 'XL'].indexOf(String(v)) };Clipboard
A column's text is its format, and copy uses it; pasted and typed text comes back through its parse. Column
types set both. See Clipboard for the grid-level copy/paste API and event payloads.
const columns: ColumnDef<Person>[] = [
{
field: 'score',
header: 'Score',
format: ({ value }) => (value == null ? '' : `${value} pts`),
parse: ({ text }) => (text.trim() === '' ? null : Number.parseInt(text, 10) || undefined),
},
];Header Groups
Group related columns under a shared spanning header label with headerGroup. Columns with the same label at
the same position are merged into one spanning header cell automatically — no separate registration step.
const columns: ColumnDef<Person>[] = [
{ field: 'name', header: 'Name', width: 160 },
{ field: 'q1Revenue', header: 'Q1', headerGroup: 'Revenue', width: 100 },
{ field: 'q2Revenue', header: 'Q2', headerGroup: 'Revenue', width: 100 },
{ field: 'q1Cost', header: 'Q1', headerGroup: 'Costs', width: 100 },
{ field: 'q2Cost', header: 'Q2', headerGroup: 'Costs', width: 100 },
];Nesting groups
headerGroup also accepts an array for nested group levels, outermost to innermost — headerGroup: ['Financials', 'Revenue'] renders Financials
> Revenue > the leaf column header. There is no separate headerGroupLevel field; the array's order is the nesting.
Auto-sizing
Double-click a column's resize handle to auto-size it to fit its content. The same behavior is available
programmatically through GridApi:
// Resize a single column to fit its widest cell (header included by default)
api.autoSizeColumn('price');
// Resize all visible columns at once
api.autoSizeAllColumns();
// With options
api.autoSizeColumn('name', { padding: 24, includeHeader: true });
api.autoSizeAllColumns({ padding: 16, minWidth: 60, maxWidth: 400 });| Option | Type | Default | Description |
|---|---|---|---|
padding | number | 16 | Extra pixels added to the measured content width. |
includeHeader | boolean | true | Include the header cell text in the width measurement. |
minWidth | number | — | Clamp the result to at least this many pixels. |
maxWidth | number | — | Clamp the result to at most this many pixels. |
autoSizeAllColumns accepts the same options and applies them uniformly to every visible column.
Displays
A display is a way to show a field, defined once on the grid and named by any column that uses it. The column's
cells, the workspace's cards and the record inspector all draw it, and export and copy use its format:
import { Grid, type ColumnDef, type GridDisplays } from '@witbybit/wit-grid-react';
type Deal = { id: string; company: string; health: number };
declare const deals: Deal[];
declare function ring(score: number, size: number): HTMLElement;
const displays: GridDisplays<Deal> = {
health: {
label: 'Health ring',
appliesTo: (column) => column.field === 'health',
render: (value, { surface }) => ring(Number(value), surface === 'card' ? 18 : 22),
format: (value) => `${Number(value)}%`,
},
};
const columns: ColumnDef<Deal>[] = [
{ field: 'company', header: 'Company' },
{ field: 'health', header: 'Health', display: 'health' },
];
export function Deals() {
return <Grid rows={deals} columns={columns} displays={displays} />;
}render returns an element, text, or null for nothing, and is told the rowId, the row, the column, the
api and the surface it draws on (cell, card or inspector). format is the value as text, for export, copy and
wherever only text fits: the format of every column that names the display and sets none of its own. displays is a grid option and a live
<Grid> prop, and api.setOptions({ displays }) replaces them. A column's own renderer still wins for its cells.
Capabilities
What may happen in a column is one rule per action: canEdit, canSelect, canCopy, canPaste, canGroup,
canFill, canSort, canFilter, canPin, canResize, canDelete, canExpand, canDrag, canMoveColumn and
canExport; the grid's capabilities also take canCreate (adding records, judged on the record as it would be
added). Each is true, false, the reason people are shown when it isn't allowed, or a function of the target
(the row, the column, the source) that answers the same way. Everything is allowed until a rule says otherwise.
const columns: ColumnDef<Person>[] = [
{
field: 'score',
header: 'Score',
canEdit: ({ row }) => (row?.team === 'Locked' ? 'The Locked team is read-only' : true),
canSort: false,
canExport: 'Scores stay in the app',
},
];The grid's capabilities option takes the same rules for every column (capabilities: { canEdit: … }). The
column's rule is asked first, then the grid's, and the first that refuses says why. Every surface asks both the same
way, through api.can(action, { rowId, colField }): cells it refuses to edit show as read-only, the header and the
sort panel don't sort a column that refuses sort, commands it refuses are left out of the menus, keys don't run
them, and a write it refuses is reported by writeBlocked. canSelect: false stops a cell range from starting in
the column, which suits action, checkbox and drag-handle columns.
Last updated on