# Columns

> Column definition: accessors, headers, formatting, cell snippets, grouped headers, pinning, visibility, resize and reorder, ellipsis and spans.

Page: https://svelte-lean.vercel.app/table/columns · Markdown: https://svelte-lean.vercel.app/table/columns.md · Index: https://svelte-lean.vercel.app/llms.txt

A column is a plain object: what it reads, how it renders, how it sorts and filters, and how it lays out. Snippets are the only part that ties a column to a Svelte file.

- **Source**: types.ts (Column)core.ts (headerRows, visibleColumns)data-table.svelte
- **Tests**: core.test.tsdata-table.dom.test.tstable.test.tstable.spec.ts (browser)
- **Fixture**: fixtures/table-basic
- **Benchmark**: table.bench.spec.ts

Column definitions are separate from rendering mechanics on purpose. The engine reads a cell value through `accessor`: a field name, a dot path such as `address.city`, or a function. The renderer turns the value into text with `format`, or hands it to a `cell` snippet with the row, the row id, the index and the column. No component instance is created per cell; the only per-cell code is the snippet you supply.

Layout is data too. `width`, `pin`, `hideable` and `resizable` are the initial values; what the user changes lands in the state fields `widths`, `pinning`, `hidden` and `order`, so a persisted state restores the layout and a removed column drops out cleanly.

## Custom cells

Render product-specific content with typed Svelte snippets while the table keeps layout and semantics.

CustomCells.svelte

```svelte
<script lang="ts">
	import { DataTable, type Column } from '@svelte-lean/table';
	import { people, type Person } from '$lib/demo/data';

	const data = people(6);
	// A cell snippet receives { row, value, rowId, index, column }.
	const columns: Column<Person>[] = [
		{ id: 'name', header: 'Member', cell: member },
		{ id: 'status', header: 'Status', cell: status },
		{ id: 'team', header: 'Team' },
		{ id: 'progress', header: 'Progress', align: 'end', cell: progress }
	];
</script>

{#snippet member({ row }: { row: Person })}
	<span class="member"><strong>{row.name}</strong><small>{row.email}</small></span>
{/snippet}
{#snippet status({ value }: { value: unknown })}
	<span class="status" data-status={value}>{value}</span>
{/snippet}
{#snippet progress({ value }: { value: unknown })}
	<meter min="0" max="100" value={Number(value)}>{value}%</meter>
{/snippet}

<DataTable {data} {columns} getRowId={(row) => row.id} pagination={false} label="Members: custom cells" />
```

Three snippets: a two-line member cell, a status with a marker drawn in CSS, and a native meter for progress. The snippet is called with a CellContext; the cell element, its padding and its alignment stay the table's.

- Text content of a snippet is searched only through the column's accessor value.
- Interactive elements inside a snippet keep their own semantics; a click on them does not trigger `onRowClick`.
- `headerCell` does the same for a leaf header and receives the sort context; see [custom sort controls](https://svelte-lean.vercel.app/table/sorting.md#custom-sort-controls).

## Widths and empty values

`width` is optional. With the default `tableLayout="fixed"` every column gets a `<col>` width (160 pixels when none is given) and the table lays out without reading its rows. With `tableLayout="auto"`, a column without a width, a resized width or a pin gets no width at all, so the browser sizes it from its content: short columns hug their values and the widest take the remaining space. Pinned columns always keep a width, because the sticky offset of the next pinned column is computed from it. Header cells and `<col>` elements carry `data-column` for application CSS; `headerClass` adds a class to the header cell and `class`, a string or a function of the row, to the body cells.

`placeholder` is the text of an empty value: `null`, `undefined`, an empty string, or a `format` that returns `null` or an empty string. It is drawn muted, and a `cell` snippet decides for itself.

`widths, classes and placeholders`

```svelte
const columns: Column<Invoice>[] = [
	// No width: with tableLayout="auto" the browser sizes the column to its content.
	{ id: 'customer', header: 'Customer' },
	{ id: 'note', header: 'Note', format: (value) => (value ? String(value) : null) },
	// A width is a starting size; resizing writes state.widths.
	{ id: 'amount', header: 'Amount', width: 120, align: 'end', headerClass: 'numeric' },
	// A per-row class for the body cells.
	{ id: 'status', header: 'Status', class: (row) => `status-${row.status}` },
	// Pinned columns need a width for their sticky offsets; 160 when none is given.
	{ id: 'actions', header: 'Actions', width: 96, pin: 'right', sortable: false }
];

<DataTable {data} {columns} getRowId={(row) => row.id} tableLayout="auto" placeholder="—" />
```

## Grouped headers

Describe multi-level headers with nested column definitions.

GroupedHeaders.svelte

```svelte
<script lang="ts">
	import { DataTable, type Column } from '@svelte-lean/table';
	import { memberColumns, pick } from '$lib/demo/columns';
	import { people, type Person } from '$lib/demo/data';

	const data = people(8);
	const base = memberColumns();
	// A column with children renders as a group header spanning its leaves.
	const columns: Column<Person>[] = [
		{ id: 'identity', header: 'Identity', children: pick(base, ['name', 'status']) },
		{ id: 'organization', header: 'Organization', children: pick(base, ['team', 'region']) },
		{ id: 'activity', header: 'Activity', children: pick(base, ['revenue', 'progress', 'joined']) }
	];
</script>

<DataTable {data} {columns} getRowId={(row) => row.id} pagination={false} label="Members: grouped headers" />
```

A parent column with children renders as a header cell with scope=colgroup spanning its leaves. When reordering or pinning separates the leaves of a group, each contiguous fragment renders with its own span rather than a misleading single cell.

## Pinned columns

Pin identity and action columns while wide records scroll horizontally.

PinnedColumns.svelte

```svelte
<script lang="ts">
	import { DataTable } from '@svelte-lean/table';
	import { memberColumns } from '$lib/demo/columns';
	import { people } from '$lib/demo/data';

	const data = people(12);
	// pin is the initial edge; tableState.pinning overrides it at run time.
	const columns = memberColumns().map((column) =>
		column.id === 'name'
			? { ...column, pin: 'left' as const }
			: column.id === 'joined'
				? { ...column, pin: 'right' as const }
				: { ...column, width: 220 }
	);
</script>

<DataTable {data} {columns} getRowId={(row) => row.id} height="20rem" pagination={false} label="Members: pinned columns" />
```

Pinned cells are position: sticky with logical insets (inset-inline-start and -end), so the same columns stay at the leading and trailing edges under dir=rtl. The RTL checkbox sets dir on the frame; the table has no dir prop and follows it.

- The offset of the second pinned column is the width of the first, read from the state.
- With a grip column (`onRowMove`), the start offset adds `--slean-table-move-width`; with a selection column also `--slean-table-selection-width`, and with `expandColumn` also `--slean-table-expand-width`.
- Pinned cells carry `data-pin="left|right"`; the last start-pinned and the first end-pinned cells also carry `data-pin-edge`, where the stylesheet draws a line.
- The shade beside each pinned side is one element in the scroll region, `.slean-table-pin-shade[data-pin="left|right"]`, as tall as the table: a gradient in `--slean-table-pin-shadow`, `--slean-table-pin-shadow-size` wide. It is drawn once per side, not once per row. It shows only while content lies past that edge (scroll-state queries; where they are missing, while the rows overflow sideways). A table without pinned columns renders none.

### Pins on narrow screens

A pin that holds most of a phone screen leaves no room for the columns it is meant to frame. The table measures the width of its visible region and keeps its pins within `pinBudget` of it (`0.4` by default; `false` keeps every pin). Past the budget, pins are let go one at a time and the column scrolls with the rest; when the region widens again they come back. Nothing is written to `pinning`, so the state and a saved layout keep what the user chose.

- The order is the column's `pinPriority` (default `0`), lowest first; among equals the innermost pin goes first, end pins before start pins. Give an action column a higher priority than an id and a phone keeps the actions.
- `pinFrom` is a hard floor: below that region width the column is not pinned, whatever the budget.
- The width is read by one `ResizeObserver` on the scroll region, and the decision is `holdPins()`, a pure function exported from `@svelte-lean/table/core` for tests and custom renderers.

## Visibility

Let users hide fields while protecting required columns.

Visibility.svelte

```svelte
<script lang="ts">
	import { onMount } from 'svelte';
	import { DataTable, defaultState, type TableState } from '@svelte-lean/table';
	import type { MenuSelectDetail } from '@svelte-lean/primitives/menu';
	import '@svelte-lean/styles/popover.css';
	import '@svelte-lean/styles/menu.css';
	import { memberColumns } from '$lib/demo/columns';
	import { people } from '$lib/demo/data';

	const data = people(6);
	const columns = memberColumns();
	let tableState = $state<Partial<TableState>>(defaultState({ hidden: ['progress'] }));
	let menu: HTMLElement;

	// The menu behavior toggles aria-checked and dispatches slean:select; the table state follows.
	onMount(() => {
		const onSelect = (event: Event) => {
			const { value, checked } = (event as CustomEvent<MenuSelectDetail>).detail;
			event.preventDefault(); // keep the menu open for the next column
			const hidden = tableState.hidden ?? [];
			tableState = { ...tableState, hidden: checked ? hidden.filter((id) => id !== value) : [...hidden, value] };
		};
		menu.addEventListener('slean:select', onSelect);
		return () => menu.removeEventListener('slean:select', onSelect);
	});
</script>

<button
	type="button"
	id="columns-button"
	data-slean="button"
	data-variant="outlined"
	data-color="neutral"
	popovertarget="columns-menu"
	aria-haspopup="menu"
>
	Columns
</button>
<div
	id="columns-menu"
	popover="auto"
	role="menu"
	aria-labelledby="columns-button"
	data-slean="menu"
	bind:this={menu}
>
	{#each columns as column (column.id)}
		<button
			type="button"
			role="menuitemcheckbox"
			aria-checked={!(tableState.hidden ?? []).includes(column.id)}
			data-slean-value={column.id}
			disabled={column.hideable === false}
		>
			{column.header}
		</button>
	{/each}
</div>

<DataTable {data} {columns} getRowId={(row) => row.id} bind:state={tableState} pagination={false} label="Members: column visibility" />
```

The dropdown is the Menu primitive from @svelte-lean/primitives with menuitemcheckbox items; its slean:select event writes state.hidden, and cancelling the event keeps the menu open for the next column. Member has hideable: false and renders as a disabled item.

- The menu behavior is discovered from the static `data-slean="menu"` marker by the site's own Vite plugin.
- The built-in `columnControls` prop renders the same toggles inside a details panel without a primitive.
- Search reads visible columns only, so a hidden column never matches.

## Resize and reorder

Resize with pointer or keyboard and persist an explicit column order.

ResizeReorder.svelte

```svelte
<script lang="ts">
	import { DataTable, defaultState, type TableState } from '@svelte-lean/table';
	import { memberColumns } from '$lib/demo/columns';
	import { people } from '$lib/demo/data';

	const data = people(6);
	const columns = memberColumns();
	// Widths, order and pins live in the state: persist it and the layout comes back.
	let tableState = $state<Partial<TableState>>(defaultState());
</script>

<DataTable {data} {columns} getRowId={(row) => row.id} bind:state={tableState} columnControls pagination={false} label="Members: resize and reorder" />

<pre>{JSON.stringify({ widths: tableState.widths, order: tableState.order, pinning: tableState.pinning })}</pre>
```

Drag a header edge or focus the resize handle and press the arrow keys; open Columns to move a column earlier or later and to pin it. The pre element prints the three state fields as they change.

- Widths are clamped to `minWidth` and `maxWidth` in the engine, not in the renderer.
- `order` lists leaf ids; columns missing from it keep their definition order after the listed ones.

## Ellipsis

Constrain long values with optional native title disclosure.

Ellipsis.svelte

```svelte
<script lang="ts">
	import { DataTable, type Column } from '@svelte-lean/table';
	import { people, type Person } from '$lib/demo/data';

	const data = people(6);
	const columns: Column<Person>[] = [
		{ id: 'name', header: 'Member', width: 120, ellipsis: { showTitle: true } },
		{ id: 'email', header: 'Email', width: 160, ellipsis: true },
		{ id: 'team', header: 'Team', width: 110 },
		{ id: 'region', header: 'Region', width: 110 }
	];
</script>

<DataTable {data} {columns} getRowId={(row) => row.id} pagination={false} label="Members: ellipsis" />
```

ellipsis: true clips with text-overflow; the object form with showTitle: true also sets the cell's title attribute to the full value so it can be read on hover and by assistive technology that exposes titles.

## Cell spans

Merge adjacent cells with row and column span callbacks.

CellSpans.svelte

```svelte
<script lang="ts">
	import { DataTable, type Column } from '@svelte-lean/table';
	import { memberColumns } from '$lib/demo/columns';
	import { people, type Person } from '$lib/demo/data';

	const data = people(6);
	// The first row's name cell spans two columns; the covered status cell returns { columns: 0 }.
	const columns: Column<Person>[] = memberColumns().map((column) =>
		column.id === 'name'
			? { ...column, span: ({ row }) => (row.id === 1 ? { columns: 2 } : undefined) }
			: column.id === 'status'
				? { ...column, span: ({ row }) => (row.id === 1 ? { columns: 0 } : undefined) }
				: column
	);
</script>

<DataTable {data} {columns} getRowId={(row) => row.id} pagination={false} label="Members: cell spans" />
```

The first row's Member cell spans two columns and the Status cell it covers returns a zero column span, so it is not rendered. Keyboard navigation finds cells by their data-column attribute, so a span does not shift the target of the arrow keys.

## API

| Name | Of | Type | Default | Description |
| --- | --- | --- | --- | --- |
| [`accessor`](https://svelte-lean.vercel.app/table/api.md#column-accessor) | Data and content | `keyof T \| string \| ((row: T) => unknown)` | `column.id` | A field, a dot path or a function. Function accessors cannot be edited through applyEdit(). |
| [`cell`](https://svelte-lean.vercel.app/table/api.md#column-cell) | Data and content | `Snippet<[CellContext<T>]>` | `undefined` | Custom body cell with row, value, rowId, index and column. |
| [`headerCell`](https://svelte-lean.vercel.app/table/api.md#column-headercell) | Data and content | `Snippet<[HeaderContext<T>]>` | `undefined` | Custom leaf header content. Receives the column, its sort, the sort list, toggleSort() and setSorting(); with sortable: false it can host a sort control of its own. |
| [`format`](https://svelte-lean.vercel.app/table/api.md#column-format) | Data and content | `(value: unknown, row: T) => string \| null` | `String(value)` | Formats the plain text of a cell and of a CSV export without a snippet; null or an empty string renders the placeholder. |
| [`children`](https://svelte-lean.vercel.app/table/api.md#column-children) | Data and content | `Column<T>[]` | `undefined` | Nested columns; the parent renders as a group header. |
| [`width`](https://svelte-lean.vercel.app/table/api.md#column-width) | Layout | `number` | `160` | Initial width in pixels. Optional with tableLayout auto, where a column without one sizes to content; pinned columns fall back to 160 for their offsets. |
| [`headerClass`](https://svelte-lean.vercel.app/table/api.md#column-headerclass) | Layout | `string` | `undefined` | Class on the leaf header cell. |
| [`class`](https://svelte-lean.vercel.app/table/api.md#column-class) | Layout | `string \| ((row: T) => string)` | `''` | Class on every body cell of the column, or one computed per row. |
| [`tableLayout`](https://svelte-lean.vercel.app/table/api.md#prop-tablelayout) | Presentation | `'auto' \| 'fixed'` | `'fixed'` | The CSS table-layout of the \<table\>. fixed applies every column width; auto leaves columns without width, resize or pin to the browser, which sizes them from content. |
| [`placeholder`](https://svelte-lean.vercel.app/table/api.md#prop-placeholder) | Presentation | `string` | `''` | Text of an empty value (null, undefined, empty string, or a format result of null or empty string). Not applied to cell snippets. |
| [`minWidth`](https://svelte-lean.vercel.app/table/api.md#column-minwidth) | Layout | `number` | `64` | Lower bound of resizing. |
| [`maxWidth`](https://svelte-lean.vercel.app/table/api.md#column-maxwidth) | Layout | `number` | `1200` | Upper bound of resizing. |
| [`pin`](https://svelte-lean.vercel.app/table/api.md#column-pin) | Layout | `'left' \| 'right' \| false` | `false` | Initial sticky edge; state.pinning overrides it. |
| [`hideable`](https://svelte-lean.vercel.app/table/api.md#column-hideable) | Layout | `boolean` | `true` | Whether the column controls may hide it. |
| [`resizable`](https://svelte-lean.vercel.app/table/api.md#column-resizable) | Layout | `boolean` | `true` | Whether the header shows a resize handle. |
| [`ellipsis`](https://svelte-lean.vercel.app/table/api.md#column-ellipsis) | Layout | `boolean \| { showTitle?: boolean }` | `false` | Clips overflow with an ellipsis; showTitle exposes the full text as a title attribute. |
| [`span`](https://svelte-lean.vercel.app/table/api.md#column-span) | Layout | `(context: CellContext<T>) => CellSpan \| undefined` | `undefined` | rowspan and colspan of a cell; a zero suppresses the cell covered by a span. |
| [`columnControls`](https://svelte-lean.vercel.app/table/api.md#prop-columncontrols) | Controls | `boolean` | `false` | Shows the visibility, order and pin controls in a \<details\> panel. |
| [`height`](https://svelte-lean.vercel.app/table/api.md#prop-height) | Presentation | `string` | `undefined` | max-height of the scroll region; with it the region is a Tab stop. |
| [`hidden`](https://svelte-lean.vercel.app/table/api.md#state-hidden) | TableState | `string[]` | `[]` | Hidden column ids. |
| [`order`](https://svelte-lean.vercel.app/table/api.md#state-order) | TableState | `string[]` | `[]` | Explicit leaf column order. |
| [`widths`](https://svelte-lean.vercel.app/table/api.md#state-widths) | TableState | `Record<string, number>` | `{}` | Resized widths by column id. |
| [`pinning`](https://svelte-lean.vercel.app/table/api.md#state-pinning) | TableState | `Record<string, Pin>` | `{}` | Pin overrides by column id. |
| [`toggleColumn`](https://svelte-lean.vercel.app/table/api.md#table-togglecolumn) | Table | `(id: string, visible?: boolean) => void` | – | Hides or shows a column. |
| [`moveColumn`](https://svelte-lean.vercel.app/table/api.md#table-movecolumn) | Table | `(id: string, delta: number) => void` | – | Moves a visible column delta places. |
| [`resizeColumn`](https://svelte-lean.vercel.app/table/api.md#table-resizecolumn) | Table | `(id: string, width: number) => void` | – | Stores a clamped width. |
| [`pin`](https://svelte-lean.vercel.app/table/api.md#table-pin) | Table | `(id: string, pin: Pin) => void` | – | Pins a column. |

## Related

- [Layout](https://svelte-lean.vercel.app/table/layout.md)
- [State persistence](https://svelte-lean.vercel.app/table/state-persistence.md)
- [Localization & RTL](https://svelte-lean.vercel.app/table/localization-rtl.md)
