Table Structure
Columns
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.
- Fixture
- Benchmark
On this page
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.
Amara Okaforamara.okafor@harbor.studio | Active | Engineering | |
Jonas Lindqvistjonas.lindqvist@harbor.studio | Active | Design | |
Mei Tanakamei.tanaka@harbor.studio | Away | Operations | |
Rafael Duarterafael.duarte@harbor.studio | Invited | Growth | |
Selin Aydınselin.aydin@harbor.studio | Active | Engineering | |
Noah Brennernoah.brenner@harbor.studio | Active | Design |
<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" />- 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. headerCelldoes the same for a leaf header and receives the sort context; see 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.
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.
| Identity | Organization | Activity | ||||
|---|---|---|---|---|---|---|
Amara Okafor | Active | Engineering | Europe | $4,501 | 20% | 2026-01-07 |
Jonas Lindqvist | Active | Design | Americas | $12,420 | 33% | 2026-06-18 |
Mei Tanaka | Away | Operations | Asia | $20,339 | 46% | 2026-02-01 |
Rafael Duarte | Invited | Growth | Europe | $28,258 | 59% | 2026-07-12 |
Selin Aydın | Active | Engineering | Americas | $36,177 | 72% | 2026-03-23 |
Noah Brenner | Active | Design | Asia | $44,096 | 85% | 2026-08-06 |
Priya Raman | Away | Operations | Europe | $4,015 | 98% | 2026-04-17 |
Elias Moreau | Invited | Growth | Americas | $11,934 | 30% | 2026-09-28 |
<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" />Pinned columns
Pin identity and action columns while wide records scroll horizontally.
Amara Okafor | Active | Engineering | Europe | $4,501 | 20% | 2026-01-07 |
Jonas Lindqvist | Active | Design | Americas | $12,420 | 33% | 2026-06-18 |
Mei Tanaka | Away | Operations | Asia | $20,339 | 46% | 2026-02-01 |
Rafael Duarte | Invited | Growth | Europe | $28,258 | 59% | 2026-07-12 |
Selin Aydın | Active | Engineering | Americas | $36,177 | 72% | 2026-03-23 |
Noah Brenner | Active | Design | Asia | $44,096 | 85% | 2026-08-06 |
Priya Raman | Away | Operations | Europe | $4,015 | 98% | 2026-04-17 |
Elias Moreau | Invited | Growth | Americas | $11,934 | 30% | 2026-09-28 |
Hana Kowalska | Active | Engineering | Asia | $19,853 | 43% | 2026-05-11 |
Ömer Say | Active | Design | Europe | $27,772 | 56% | 2026-01-22 |
İpek Yılmaz | Away | Operations | Americas | $35,691 | 69% | 2026-06-05 |
Lucas Ferreira | Invited | Growth | Asia | $43,610 | 82% | 2026-02-16 |
<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" />- The offset of the second pinned column is the width of the first, read from the state.
- With a selection column, the start offset adds
--slean-table-selection-width, and withexpandColumnalso--slean-table-expand-width. - Pinned cells carry
data-pin="left|right"; the last start-pinned and the first end-pinned cells also carrydata-pin-edge, where the stylesheet draws a line and a shadow in--slean-table-pin-shadow.
Visibility
Let users hide fields while protecting required columns.
Amara Okafor | Active | Engineering | Europe | $4,501 | 2026-01-07 |
Jonas Lindqvist | Active | Design | Americas | $12,420 | 2026-06-18 |
Mei Tanaka | Away | Operations | Asia | $20,339 | 2026-02-01 |
Rafael Duarte | Invited | Growth | Europe | $28,258 | 2026-07-12 |
Selin Aydın | Active | Engineering | Americas | $36,177 | 2026-03-23 |
Noah Brenner | Active | Design | Asia | $44,096 | 2026-08-06 |
<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="outline"
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 menu behavior is discovered from the static
data-slean="menu"marker by the site's own Vite plugin. - The built-in
columnControlsprop 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.
Amara Okafor | Active | Engineering | Europe | $4,501 | 20% | 2026-01-07 |
Jonas Lindqvist | Active | Design | Americas | $12,420 | 33% | 2026-06-18 |
Mei Tanaka | Away | Operations | Asia | $20,339 | 46% | 2026-02-01 |
Rafael Duarte | Invited | Growth | Europe | $28,258 | 59% | 2026-07-12 |
Selin Aydın | Active | Engineering | Americas | $36,177 | 72% | 2026-03-23 |
Noah Brenner | Active | Design | Asia | $44,096 | 85% | 2026-08-06 |
{"widths":{},"order":[],"pinning":{}} <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>- Widths are clamped to
minWidthandmaxWidthin the engine, not in the renderer. orderlists leaf ids; columns missing from it keep their definition order after the listed ones.
Ellipsis
Constrain long values with optional native title disclosure.
Amara Okafor | amara.okafor@harbor.studio | Engineering | Europe |
Jonas Lindqvist | jonas.lindqvist@harbor.studio | Design | Americas |
Mei Tanaka | mei.tanaka@harbor.studio | Operations | Asia |
Rafael Duarte | rafael.duarte@harbor.studio | Growth | Europe |
Selin Aydın | selin.aydin@harbor.studio | Engineering | Americas |
Noah Brenner | noah.brenner@harbor.studio | Design | Asia |
<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" />Cell spans
Merge adjacent cells with row and column span callbacks.
Amara Okafor | Engineering | Europe | $4,501 | 20% | 2026-01-07 | |
Jonas Lindqvist | Active | Design | Americas | $12,420 | 33% | 2026-06-18 |
Mei Tanaka | Away | Operations | Asia | $20,339 | 46% | 2026-02-01 |
Rafael Duarte | Invited | Growth | Europe | $28,258 | 59% | 2026-07-12 |
Selin Aydın | Active | Engineering | Americas | $36,177 | 72% | 2026-03-23 |
Noah Brenner | Active | Design | Asia | $44,096 | 85% | 2026-08-06 |
<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" />API
| Name | Of | Type | Default | Description |
|---|---|---|---|---|
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 | Data and content | Snippet<[CellContext<T>]> | undefined | Custom body cell with row, value, rowId, index and 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 | 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 | Data and content | Column<T>[] | undefined | Nested columns; the parent renders as a group header. |
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 | Layout | string | undefined | Class on the leaf header cell. |
class | Layout | string | ((row: T) => string) | '' | Class on every body cell of the column, or one computed per row. |
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 | 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 | Layout | number | 64 | Lower bound of resizing. |
maxWidth | Layout | number | 1200 | Upper bound of resizing. |
pin | Layout | 'left' | 'right' | false | false | Initial sticky edge; state.pinning overrides it. |
hideable | Layout | boolean | true | Whether the column controls may hide it. |
resizable | Layout | boolean | true | Whether the header shows a resize handle. |
ellipsis | Layout | boolean | { showTitle?: boolean } | false | Clips overflow with an ellipsis; showTitle exposes the full text as a title attribute. |
span | Layout | (context: CellContext<T>) => CellSpan | undefined | undefined | rowspan and colspan of a cell; a zero suppresses the cell covered by a span. |
columnControls | Controls | boolean | false | Shows the visibility, order and pin controls in a <details> panel. |
height | Presentation | string | undefined | max-height of the scroll region; with it the region is a Tab stop. |
hidden | TableState | string[] | [] | Hidden column ids. |
order | TableState | string[] | [] | Explicit leaf column order. |
widths | TableState | Record<string, number> | {} | Resized widths by column id. |
pinning | TableState | Record<string, Pin> | {} | Pin overrides by column id. |
toggleColumn | Table | (id: string, visible?: boolean) => void | – | Hides or shows a column. |
moveColumn | Table | (id: string, delta: number) => void | – | Moves a visible column delta places. |
resizeColumn | Table | (id: string, width: number) => void | – | Stores a clamped width. |
pin | Table | (id: string, pin: Pin) => void | – | Pins a column. |