sveltelean Table
Versionv0.2.0 GitHub

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.

Custom cells
Amara Okaforamara.okafor@harbor.studio
Active
Engineering
20%
Jonas Lindqvistjonas.lindqvist@harbor.studio
Active
Design
33%
Mei Tanakamei.tanaka@harbor.studio
Away
Operations
46%
Rafael Duarterafael.duarte@harbor.studio
Invited
Growth
59%
Selin Aydınselin.aydin@harbor.studio
Active
Engineering
72%
Noah Brennernoah.brenner@harbor.studio
Active
Design
85%

CustomCells.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.

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

Grouped headers
IdentityOrganizationActivity
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

GroupedHeaders.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.

Pinned columns
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

PinnedColumns.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 selection column, the start offset adds --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 and a shadow in --slean-table-pin-shadow.

Visibility

Let users hide fields while protecting required columns.

Column visibility
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

Visibility.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="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 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.

Resize and reorder
Columns
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":{}}

ResizeReorder.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
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

Ellipsis.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.

Cell spans
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

CellSpans.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

NameOfTypeDefaultDescription
accessorData and contentkeyof T | string | ((row: T) => unknown)column.idA field, a dot path or a function. Function accessors cannot be edited through applyEdit().
cellData and contentSnippet<[CellContext<T>]>undefinedCustom body cell with row, value, rowId, index and column.
headerCellData and contentSnippet<[HeaderContext<T>]>undefinedCustom 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.
formatData and content(value: unknown, row: T) => string | nullString(value)Formats the plain text of a cell and of a CSV export without a snippet; null or an empty string renders the placeholder.
childrenData and contentColumn<T>[]undefinedNested columns; the parent renders as a group header.
widthLayoutnumber160Initial width in pixels. Optional with tableLayout auto, where a column without one sizes to content; pinned columns fall back to 160 for their offsets.
headerClassLayoutstringundefinedClass on the leaf header cell.
classLayoutstring | ((row: T) => string)''Class on every body cell of the column, or one computed per row.
tableLayoutPresentation'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.
placeholderPresentationstring''Text of an empty value (null, undefined, empty string, or a format result of null or empty string). Not applied to cell snippets.
minWidthLayoutnumber64Lower bound of resizing.
maxWidthLayoutnumber1200Upper bound of resizing.
pinLayout'left' | 'right' | falsefalseInitial sticky edge; state.pinning overrides it.
hideableLayoutbooleantrueWhether the column controls may hide it.
resizableLayoutbooleantrueWhether the header shows a resize handle.
ellipsisLayoutboolean | { showTitle?: boolean }falseClips overflow with an ellipsis; showTitle exposes the full text as a title attribute.
spanLayout(context: CellContext<T>) => CellSpan | undefinedundefinedrowspan and colspan of a cell; a zero suppresses the cell covered by a span.
columnControlsControlsbooleanfalseShows the visibility, order and pin controls in a <details> panel.
heightPresentationstringundefinedmax-height of the scroll region; with it the region is a Tab stop.
hiddenTableStatestring[][]Hidden column ids.
orderTableStatestring[][]Explicit leaf column order.
widthsTableStateRecord<string, number>{}Resized widths by column id.
pinningTableStateRecord<string, Pin>{}Pin overrides by column id.
toggleColumnTable(id: string, visible?: boolean) => void–Hides or shows a column.
moveColumnTable(id: string, delta: number) => void–Moves a visible column delta places.
resizeColumnTable(id: string, width: number) => void–Stores a clamped width.
pinTable(id: string, pin: Pin) => void–Pins a column.