Columns
Defining columns: accessors, column types, sizes, pinning, visibility and what an accessor may return.
@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3
This guide is the package's docs/, included unchanged from
the package as it is in Foundation. Links to the package's internal design
documents keep their text.
// apps/docs/examples/grid/client-orders.tsx'use client';
import '@workspace/active-grid/styles.css';
import {useMemo} from 'react';import {generateOrders, type Order} from '@/lib/examples/orders';
import { composePresentationContext, toIanaTimeZone,} from '@workspace/active-format';import { ActiveGrid, createColumnHelper, englishGridTranslator, type GridColumnDef,} from '@workspace/active-grid';
// The presentation: language, notation and the time zone the date filter// needs for its relative presets and calendar.const presentation = composePresentationContext( 'en-GB', null, toIanaTimeZone('Europe/Amsterdam'),);
const STATUS = [ {label: 'Pending', value: 'pending'}, {label: 'Processing', value: 'processing'}, {label: 'Shipped', value: 'shipped'}, {label: 'Delivered', value: 'delivered'}, {label: 'Cancelled', value: 'cancelled'},];
const ch = createColumnHelper<Order>();const columns: GridColumnDef<Order>[] = [ ch.accessor('id', {header: 'Order', meta: {filterType: 'text'}}), ch.accessor('customer', {header: 'Customer', meta: {filterType: 'text'}}), ch.accessor('category', { header: 'Category', meta: {filterType: 'select'}, }), ch.accessor('quantity', {header: 'Qty', type: 'number'}), ch.accessor('total', { header: 'Total', type: 'currency', meta: {currencyCode: 'EUR', filterType: 'number'}, }), ch.accessor('orderDate', {header: 'Ordered', type: 'date'}), ch.accessor('status', { header: 'Status', type: 'badge', meta: { filterType: 'select', filterOptions: STATUS, statusTones: { pending: 'warning', processing: 'info', shipped: 'info', delivered: 'success', cancelled: 'danger', }, }, }),];
/** Client mode: all 500 rows are in the browser; the grid sorts, filters and pages them there. */export default function ClientOrders() { const data = useMemo(() => generateOrders(500), []); return ( <ActiveGrid<Order> gridId="docs-client-orders" mode="client" data={data} columns={columns} height={420} locale={presentation} t={englishGridTranslator} enableRowSelection pagination={{enabled: true, pageSizeOptions: [10, 25, 50]}} toolbar={{search: {enabled: true}, showColumnChooser: true}} /> );}Comprehensive guide to configuring columns in Active Grid. For API reference
see ../.
Basic Column Definition#
Every column needs at minimum an accessor (where to get the data) and a header (what to display):
import {type GridColumnDef} from '@workspace/active-grid';
type User = {id: string; firstName: string; lastName: string; email: string};
const columns: GridColumnDef<User>[] = [ {accessorKey: 'firstName', header: 'First Name'}, {accessorKey: 'email', header: 'Email', size: 250}, { accessorFn: (row) => `${row.firstName} ${row.lastName}`, id: 'fullName', header: 'Full Name', },];accessorโ the property name on the data object. The columnKey idis derived automatically.accessorโ a function that extracts the value. Requires an explicitFn id.headerโ string for static text, or a render function for custom headers.
Stable Column IDs#
Active Grid keys all per-column state by the column id โ not by the
identity of the column object you pass in:
Order, sizing, visibility, and pinning are stored as id-keyed maps and arrays (
column,Sizing column,Visibility column,Order column).Pinning Column filters and sorting carry the column id (
{id, value}and{id, desc}).On mount, persisted (localStorage) and URL state is reconciled against the current column ids: entries whose id no longer exists are pruned, the rest are restored.
Because state is keyed by id, you can rebuild the columns array on every
render โ a new array reference with new object references โ without losing the
user's filters, sort, widths, order, or visibility. That guarantee holds on
one condition: column ids must stay stable across re-renders and across
sessions.
Give each column an id that reflects what the column means, not where it sits
or when it was created. accessor columns get this for free; accessor
columns need a stable explicit id:
// Stable โ id derived from the data field{accessorKey: 'email', header: 'Email'}
// Stable โ explicit, meaningful id for an accessorFn column{ accessorFn: (row) => `${row.firstName} ${row.lastName}`, id: 'fullName', header: 'Full Name',}Do not derive ids from the array index or from a value generated per render. The id changes on the next render, reconciliation can no longer match it to the stored state, and the user's filters / sort / sizing / order silently reset:
// Unstable โ index-based ids shift when columns are added, removed, or reorderedcolumns.map((col, i) => ({...col, id: `col-${i}`}));
// Unstable โ a new id every render, so state never reconciles{accessorFn: getFullName, id: `fullName-${Math.random()}`, header: 'Full Name'}{accessorFn: getFullName, id: crypto.randomUUID(), header: 'Full Name'}If you build columns inside a component, memoize them (or define them at module scope) so the same logical column always produces the same id.
Column Types and Presets#
Define reusable column configurations with column:
import {type GridColumnTypes} from '@workspace/active-grid';import { formatCurrency, formatFractionAsPercent,} from '@workspace/active-format';
const columnTypes: GridColumnTypes<Employee> = { currency: { size: 130, meta: { cellAlign: 'right', filterType: 'number', // Money semantics: the column declares per row (`currencyKey`) or // per contract (`currencyCode`) which currency the amounts carry; // the cell orchestration formats. Never a currency derived from the language. semanticType: 'currency', }, }, percentage: { size: 100, meta: { cellAlign: 'right', filterType: 'number', // Percentage semantics: the column declares the source unit // explicitly -- 'fraction' for 0-1 data (0.125 means 12.5%), // 'percentage-points' for 0-100 data (12.5 means 12.5%). The // cell orchestration and the aggregation paths format; never a // manual /100 in a custom formatter. semanticType: 'percent', percentSource: 'fraction', percentPreset: 'decimal-1', }, }, status: { size: 120, meta: { filterType: 'select', filterOptions: [ {label: 'Active', value: 'active'}, {label: 'Inactive', value: 'inactive'}, ], }, },};Apply a type to a column with the type property:
const columns: GridColumnDef<Employee>[] = [ {accessorKey: 'salary', header: 'Salary', type: 'currency'}, {accessorKey: 'bonus', header: 'Bonus', type: 'currency'}, {accessorKey: 'status', header: 'Status', type: 'status'},];Multiple types can be applied as an array โ later types override earlier ones:
{accessorKey: 'rate', header: 'Rate', type: ['currency', 'percentage']}Column Defaults#
Apply defaults to all columns with column:
<ActiveGrid columnDefaults={{ size: 150, minSize: 80, enableSorting: true, meta: {truncate: true}, }}/>Priority order (highest wins): column-definition โ column-type โ column-defaults โ global config.
Value Formatters#
Format raw values for display without changing the underlying data:
{ accessorKey: 'salary', header: 'Salary', meta: { // The currency identity comes from the data or the domain contract; // for standard cases `type: 'currency'` + `currencyKey`/ // `currencyCode` suffices without a custom formatter. valueFormatter: (value: number, data: Employee) => formatCurrency({amount: value, currency: data.currency}, locale, { decimals: 0, }), },}{ accessorKey: 'startDate', header: 'Start Date', meta: { valueFormatter: (value: string) => formatDate(value, locale, 'long'), },}{ accessorKey: 'ratio', header: 'Ratio', meta: { valueFormatter: (value: number) => formatFractionAsPercent(value, 'decimal-1', locale), },}Sorting and filtering use the raw value. Cell presentation uses the formatter; export formatting depends on the export options (see Export).
Cell Renderers#
Built-in Renderers#
Active Grid includes pre-built cell renderers:
import { TextCell, BadgeCell, StatusCell, DateCell, LinkCell,} from '@workspace/active-grid';TextCell โ Default. Renders text with optional truncation and title tooltip.
BadgeCell โ Categorical badge (type, category, department). One fixed presentation, no colour prop.
StatusCell โ Status badge on a generic tone. See Status columns.
DateCell โ Formatted date display.
LinkCell โ Clickable link.
Status columns#
A status column does not need a cell renderer at all. Declare the meaning on
the column and the grid renders its own status badge, coloured by
Grid:
{ accessorKey: 'status', header: 'Status', type: 'badge', meta: { filterOptions: [ {label: 'Active', value: 'active'}, {label: 'Suspended', value: 'suspended'}, ], statusTones: {active: 'success', suspended: 'warning'}, },}Do not hand-roll status colours in a cell renderer. Colour classes written
per column drift apart, fail contrast in one of the two themes, and put the
meaning of a domain value in a place nobody looks. The tone axis is the one
place where that meaning is written down.
Custom Cell Renderer#
Use the cell property for fully custom rendering:
{ accessorKey: 'owner', header: 'Owner', cell: ({row}) => ( <span className="flex items-center gap-2"> <img className="size-5 rounded-full" src={row.original.avatarUrl} alt="" /> {row.original.ownerName} </span> ),}Filter Configuration#
Set the filter type on a column to enable filtering:
// Text filter with all operators{accessorKey: 'name', header: 'Name', meta: {filterType: 'text'}}
// Number filter{accessorKey: 'salary', header: 'Salary', meta: {filterType: 'number'}}
// Date filter{accessorKey: 'startDate', header: 'Start Date', meta: {filterType: 'date'}}
// Select filter with options{ accessorKey: 'department', header: 'Department', meta: { filterType: 'select', filterOptions: [ {label: 'Engineering', value: 'engineering'}, {label: 'Marketing', value: 'marketing'}, {label: 'Sales', value: 'sales'}, ], filterSearchable: true, filterShowSelectAll: true, },}
// Boolean filter{accessorKey: 'isActive', header: 'Active', meta: {filterType: 'boolean'}}Restricting Operators#
Use operator for common restrictions:
// Only exact match (for IDs or codes){meta: {filterType: 'text', operatorPreset: 'identity'}}
// Only contains (search-bar behavior){meta: {filterType: 'text', operatorPreset: 'searchOnly'}}
// Only range operators (for statistics){meta: {filterType: 'number', operatorPreset: 'rangeOnly'}}Or use allowed for explicit control:
{meta: {filterType: 'number', allowedOperators: ['greaterThan', 'lessThan', 'between']}}See filters. for all operators and presets.
Sorting#
Sorting is enabled by default. Disable per column:
{accessorKey: 'actions', header: '', enableSorting: false}Custom sort function:
{ accessorKey: 'priority', header: 'Priority', sortFn: (rowA, rowB, columnId) => { const order = {high: 3, medium: 2, low: 1}; return order[rowA.getValue<'high' | 'medium' | 'low'>(columnId)] - order[rowB.getValue<'high' | 'medium' | 'low'>(columnId)]; },}Built-in sort functions: alphanumeric, alphanumeric,
text, text, datetime, basic.
Editing Configuration#
Make a column editable by setting meta.:
// Always editable{accessorKey: 'name', header: 'Name', meta: {editable: true}}
// Conditionally editable{ accessorKey: 'status', header: 'Status', meta: { editable: (row) => row.status !== 'archived', editType: 'select', editOptions: [ {label: 'Active', value: 'active'}, {label: 'Inactive', value: 'inactive'}, ], },}
// With validation{ accessorKey: 'email', header: 'Email', meta: { editable: true, validate: (value: string) => { if (!value.includes('@')) return 'Invalid email address'; return undefined; }, },}
// With async validation{ accessorKey: 'username', header: 'Username', meta: { editable: true, validate: async (value: string) => { const exists = await checkUsernameExists(value); if (exists) return 'Username already taken'; return undefined; }, },}Editor types: text (default), number, date, select, boolean.
See editing. for the full editing lifecycle.
Column Sizing#
{ accessorKey: 'description', header: 'Description', size: 300, // Initial width in pixels minSize: 100, // Minimum width (default: 100) maxSize: 500, // Maximum width (default: 800)}Flex Sizing#
Use meta. for proportional column widths:
{accessorKey: 'name', header: 'Name', meta: {flex: 2}} // 2x width{accessorKey: 'email', header: 'Email', meta: {flex: 3}} // 3x width{accessorKey: 'role', header: 'Role', meta: {flex: 1}} // 1x widthAuto-sizing#
Auto-size on mount:
<ActiveGrid autoSizeOnMount />// or with options:<ActiveGrid autoSizeOnMount={{mode: 'fit-viewport', lockAfterSize: true}} />Three auto-size modes:
fit-โ Size each column to fit its widest content.content fit-โ Distribute columns to fill the viewport width.viewport balancedโ Fit content first, then distribute remaining space.
Lock / Unlock#
Columns can be locked to prevent flex resizing. The grid manages the lock internally; it is toggled from the header menu.
Column Pinning#
Enable pinning globally and pin columns via column definitions or user interaction:
<ActiveGrid enableColumnPinning />Allow pinning for a column in the definition:
{accessorKey: 'name', header: 'Name', meta: {enablePinning: true}}Users can pin columns via the header menu (right-click or hamburger icon).
Pinning is LOGICAL (ADR-053): a column is pinned to the start or the end
of the reading order, and sticks there during horizontal scroll with a shadow
separator. Under ltr the start is the left edge; under rtl the right. The
menu items name the side a reader sees ("Pin left"), and pin the logical side
it is under the grid's direction. The state is {start: string[], end:
string[]}; column..
Column Visibility#
Column visibility is managed through two mechanisms.
Column Chooser Dialog#
The primary UI for toggling column visibility. Accessed via the "Columns"
button in the toolbar (opt in with toolbar.).
Features:
Search input to filter columns by name.
Master checkbox (select all / deselect all).
Per-column checkboxes with at-least-one-visible guard.
Reset button to restore default visibility.
<ActiveGrid toolbar={{showColumnChooser: true}} />Header Menu#
Columns can also be hidden individually via the "Hide column" option in the per-column header menu.
Controlling Hiding#
Prevent specific columns from being hidden with enable:
{accessorKey: 'id', header: 'ID', enableHiding: false} // Cannot be hiddenThe Column Chooser button only appears when at least one column has
enable (the default).
Column Groups#
Create nested headers by using the columns property:
const columns: GridColumnDef<Employee>[] = [ {accessorKey: 'name', header: 'Name'}, { id: 'compensation', header: 'Compensation', columns: [ {accessorKey: 'salary', header: 'Salary', type: 'currency'}, {accessorKey: 'bonus', header: 'Bonus', type: 'currency'}, {accessorKey: 'stock', header: 'Stock Options', type: 'currency'}, ], },];This renders a group header row above the leaf column headers.
System Columns#
Active Grid provides factory functions for common system columns:
import {getSelectionColumn, getExpanderColumn} from '@workspace/active-grid';
const columns: GridColumnDef<Employee>[] = [ getSelectionColumn(), // Checkbox for row selection getExpanderColumn(), // Chevron for row expanding {accessorKey: 'name', header: 'Name'}, // ...];Row numbering is available via the show prop rather than a
factory function.
System columns have enable, enable, and no
header menu by default. They are excluded from export.
Aggregation#
Add a footer aggregation to a column:
{ accessorKey: 'salary', header: 'Salary', meta: { aggregationFn: 'sum', aggregationLabel: 'Total: ', semanticType: 'currency', currencyCode: 'EUR', },}Available aggregation functions: sum, avg, count, min, max.
In server mode, aggregation values come from the
Paged field returned by fetch.
Conditional Cell Styling#
CSS Class Rules#
Apply CSS classes conditionally:
{ accessorKey: 'balance', header: 'Balance', meta: { cellClassRules: { 'text-destructive font-medium': (value: number) => value < 0, 'text-success': (value: number) => value > 10000, }, },}Inline Styles#
Apply inline styles dynamically:
{ accessorKey: 'progress', header: 'Progress', meta: { cellStyle: (value: number) => ({ backgroundColor: `hsl(${value * 1.2}, 70%, 90%)`, }), },}Tooltips#
Show tooltips on cell hover:
// Use another field as tooltip{accessorKey: 'name', header: 'Name', meta: {tooltipField: 'fullName'}}
// Custom tooltip content{ accessorKey: 'status', header: 'Status', meta: { tooltipValueGetter: (value, row) => `${value} since ${row.statusDate}`, },}Complete Example#
import {type GridColumnDef, type GridColumnTypes} from '@workspace/active-grid';import {formatCurrency, formatDate} from '@workspace/active-format';
interface Employee { id: string; firstName: string; lastName: string; email: string; department: string; salary: number; startDate: string; isActive: boolean;}
const columnTypes: GridColumnTypes<Employee> = { currency: { size: 130, meta: { cellAlign: 'right', filterType: 'number', semanticType: 'currency', }, },};
const columns: GridColumnDef<Employee>[] = [ { accessorKey: 'firstName', header: t('employee.firstName'), meta: {filterType: 'text', editable: true, truncate: true}, }, { accessorKey: 'lastName', header: t('employee.lastName'), meta: {filterType: 'text', editable: true, truncate: true}, }, { accessorKey: 'email', header: t('employee.email'), size: 250, meta: { filterType: 'text', operatorPreset: 'searchOnly', editable: true, validate: (value: string) => { if (!value.includes('@')) return 'Invalid email'; return undefined; }, }, }, { accessorKey: 'department', header: t('employee.department'), meta: { filterType: 'select', filterOptions: departments.map((d) => ({label: d.name, value: d.id})), filterSearchable: true, }, }, { accessorKey: 'salary', header: t('employee.salary'), type: 'currency', meta: { currencyCode: 'EUR', aggregationFn: 'sum', aggregationLabel: 'Total: ', }, }, { accessorKey: 'startDate', header: t('employee.startDate'), meta: { filterType: 'date', valueFormatter: (value: string) => formatDate(value, locale, 'medium'), }, }, { accessorKey: 'isActive', header: t('employee.isActive'), size: 100, meta: {filterType: 'boolean'}, },];