Active Foundation

Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

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

The package's own guide

This guide is the package's docs/guides/columns.md, included unchanged from the package as it is in Foundation. Links to the package's internal design documents keep their text.

Orders in client modeClient

Skip past grid rows
Order
Customer
Category
Qty
Total
Ordered
Status
End of grid rows

Comprehensive guide to configuring columns in Active Grid. For API reference see ../api/columns.md.

Basic Column Definition#

Every column needs at minimum an accessor (where to get the data) and a header (what to display):

typescript
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',  },];
  • accessorKey โ€” the property name on the data object. The column id is derived automatically.

  • accessorFn โ€” a function that extracts the value. Requires an explicit 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 (columnSizing, columnVisibility, columnOrder, columnPinning).

  • 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. accessorKey columns get this for free; accessorFn columns need a stable explicit id:

typescript
// 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:

typescript
// 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 columnTypes:

typescript
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:

typescript
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:

typescript
{accessorKey: 'rate', header: 'Rate', type: ['currency', 'percentage']}

Column Defaults#

Apply defaults to all columns with columnDefaults:

typescript
<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:

typescript
{  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,      }),  },}
typescript
{  accessorKey: 'startDate',  header: 'Start Date',  meta: {    valueFormatter: (value: string) => formatDate(value, locale, 'long'),  },}
typescript
{  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:

typescript
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 GridStatusTone:

typescript
{  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:

typescript
{  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:

typescript
// 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 operatorPreset for common restrictions:

typescript
// 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 allowedOperators for explicit control:

typescript
{meta: {filterType: 'number', allowedOperators: ['greaterThan', 'lessThan', 'between']}}

See filters.md for all operators and presets.

Sorting#

Sorting is enabled by default. Disable per column:

typescript
{accessorKey: 'actions', header: '', enableSorting: false}

Custom sort function:

typescript
{  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, alphanumericCaseSensitive, text, textCaseSensitive, datetime, basic.

Editing Configuration#

Make a column editable by setting meta.editable:

typescript
// 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.md for the full editing lifecycle.

Column Sizing#

typescript
{  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.flex for proportional column widths:

typescript
{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 width

Auto-sizing#

Auto-size on mount:

TSX
<ActiveGrid autoSizeOnMount />// or with options:<ActiveGrid autoSizeOnMount={{mode: 'fit-viewport', lockAfterSize: true}} />

Three auto-size modes:

  • fit-content โ€” Size each column to fit its widest content.

  • fit-viewport โ€” Distribute columns to fill the viewport width.

  • 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:

TSX
<ActiveGrid enableColumnPinning />

Allow pinning for a column in the definition:

typescript
{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.pin('start' | 'end' | false).

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.showColumnChooser). 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.

TSX
<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 enableHiding:

typescript
{accessorKey: 'id', header: 'ID', enableHiding: false}  // Cannot be hidden

The Column Chooser button only appears when at least one column has enableHiding: true (the default).

Column Groups#

Create nested headers by using the columns property:

typescript
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:

typescript
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 showRowNumbers prop rather than a factory function.

System columns have enableSorting: false, enableResizing: false, and no header menu by default. They are excluded from export.

Aggregation#

Add a footer aggregation to a column:

typescript
{  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 PagedResponse.aggregations field returned by fetchFn.

Conditional Cell Styling#

CSS Class Rules#

Apply CSS classes conditionally:

typescript
{  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:

typescript
{  accessorKey: 'progress',  header: 'Progress',  meta: {    cellStyle: (value: number) => ({      backgroundColor: `hsl(${value * 1.2}, 70%, 90%)`,    }),  },}

Tooltips#

Show tooltips on cell hover:

typescript
// 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#

typescript
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'},  },];

This page as Markdown