---
id: "packages/active-grid/columns"
title: "Columns"
description: "Defining columns: accessors, column types, sizes, pinning, visibility and what an accessor may return."
url: "/packages/active-grid/columns"
locale: "en"
revision: "59ceceeb046b5c40"
---

# Columns

> [!NOTE]
> **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.

**Example: Orders in client mode**

Client: Runs entirely in your browser. Source: `examples/grid/client-orders.tsx`.

```tsx
// 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 `../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 reordered
columns.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'},
  },
];
```
