sveltelean Table
Versionv0.2.0 GitHub

Every feature on this page is keyed by row identity. expanded in the state is a list of row ids; a tree flattens into nodes with a depth per id; a move is reported as two ids; events carry the row itself. The renderer keeps no per-row object beyond the row model and no listener per row: drag events are handled by one listener each on the tbody, and click callbacks resolve the row from the event target.

row identity
// A row id is a finite number or a string and stays the same across renders.
getRowId={(row) => row.id}

// Not a position: an index changes when rows are sorted, filtered or moved.
getRowId={(row, index) => index} // wrong

The table never changes the data array. Reordering, adding and removing rows are application decisions expressed by giving the table a new array, which is also what makes animation, undo and server round trips possible without hidden state.

Expandable rows

Reveal one details region per record and control which rows may expand.

Expandable rows
Amara Okafor
Engineering
Europe
$4,501
Jonas Lindqvist
Design
Americas
$12,420
Mei Tanaka
Operations
Asia
$20,339
Rafael Duarte
Growth
Europe
$28,258
Selin Aydın
Engineering
Americas
$36,177
Noah Brenner
Design
Asia
$44,096

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

	const data = people(6);
	const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);
	let last = $state('');
</script>

{#snippet details(row: Person)}
	<dl class="details">
		<div><dt>Email</dt><dd>{row.email}</dd></div>
		<div><dt>Revenue</dt><dd>{money.format(row.revenue)}</dd></div>
		<div><dt>Joined</dt><dd>{row.joined}</dd></div>
	</dl>
{/snippet}

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	rowDetails={details}
	rowExpandable={(row) => row.status !== 'Invited'}
	expandRowByClick
	onExpand={(open, row) => (last = `${row.name} ${open ? 'expanded' : 'collapsed'}`)}
	pagination={false}
	label="Members: expandable rows"
/>
<p role="status">{last}</p>
A rowDetails snippet renders a full-width row under an expanded record. The toggle button carries aria-expanded; expandRowByClick also toggles on a click anywhere in a cell that is not a control. Invited members return false from rowExpandable and get no toggle.
  • onExpand reports one row; onExpandedRowsChange the complete id list.
  • Details rows are not data rows: arrow-key navigation skips them.
  • The snippet's second argument is { rowId, index, collapse }; a close button inside the details calls collapse().
  • expandColumn moves the toggle into a narrow leading column of its own, after the selection column; rows that cannot expand leave it empty. Without it, the first data column reserves the toggle's width on every row so the text stays aligned.

Nested table in row details

Show the records that belong to a row as a table of their own.

Nested table
Details
Amara Okafor
Engineering
Europe
$4,501
INV-11
2026-06
$1,500
Paid
INV-12
2026-07
$1,125
Paid
INV-13
2026-08
$900
Open
Jonas Lindqvist
Design
Americas
$12,420
Mei Tanaka
Operations
Asia
$20,339
Rafael Duarte
Growth
Europe
$28,258
Selin Aydın
Engineering
Americas
$36,177

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

	type Invoice = { id: string; month: string; amount: number; paid: boolean };

	const data = people(5);
	const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);
	const invoiceColumns: Column<Invoice>[] = [
		{ id: 'id', header: 'Invoice', width: 120 },
		{ id: 'month', header: 'Month', width: 120 },
		{ id: 'amount', header: 'Amount', align: 'end', width: 120, format: (v) => money.format(Number(v)) },
		{ id: 'paid', header: 'Status', width: 110, format: (v) => (v ? 'Paid' : 'Open') }
	];
	function invoices(member: Person): Invoice[] {
		return ['2026-06', '2026-07', '2026-08'].map((month, i) => ({
			id: `INV-${member.id}${i + 1}`,
			month,
			amount: Math.round(member.revenue / (3 + i)),
			paid: i < 2 || member.id % 2 === 0
		}));
	}
</script>

{#snippet details(row: Person, { collapse }: RowDetailsContext)}
	<div class="details">
		<DataTable
			data={invoices(row)}
			columns={invoiceColumns}
			getRowId={(invoice) => invoice.id}
			density="compact"
			striped
			pagination={false}
			label="Invoices of {row.name}"
		/>
		<button type="button" data-slean="button" data-variant="ghost" data-size="sm" onclick={collapse}>
			Close invoices
		</button>
	</div>
{/snippet}

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	rowDetails={details}
	expandColumn
	rowLabel={(row) => row.name}
	state={{ expanded: [1] }}
	pagination={false}
	label="Members: nested invoices"
/>
Each member expands into a compact, striped DataTable of invoices. The toggle has its own column (expandColumn) and is named after the member (rowLabel: Expand row: Amara Okafor). The outer table's hover, density and borders stop at the details row, and its keyboard, drag and scrollTo() never reach the nested rows.
  • The stylesheet reaches a table's rows through child combinators from its own root, and the row variable --slean-table-row-bg is reset on every root, so a nested table is styled by its own props only.
  • Every lookup the renderer makes (a cell to focus, a row to scroll to, the row of a drag or a key press) starts from its own tbody, so equal row ids in the two tables do not collide.

Tree data

Render nested records through getSubRows with configurable indentation.

Tree data
Amara Okafor
Engineering
Europe
$4,501
Engineering / Amara Okafor
Engineering
Europe
$4,501
Engineering / Jonas Lindqvist
Design
Americas
$12,420
Jonas Lindqvist
Design
Americas
$12,420
Mei Tanaka
Operations
Asia
$20,339
Rafael Duarte
Growth
Europe
$28,258
Selin Aydın
Engineering
Americas
$36,177

TreeData.svelte
<script lang="ts">
	import { DataTable } from '@svelte-lean/table';
	import { memberColumns, pick } from '$lib/demo/columns';
	import { tree } from '$lib/demo/data';

	// Two of the five rows carry a children array; getSubRows returns it.
	const data = tree();
	const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);
</script>

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	getSubRows={(row) => row.children}
	indentSize={22}
	state={{ expanded: [1] }}
	pagination={false}
	label="Members: tree data"
/>
getSubRows returns the children of a row; the engine flattens the tree, indents the first cell by depth times indentSize, and keeps a parent visible when a descendant matches a search or filter. The first parent starts expanded through state.expanded.
  • Tree input and groupBy() are separate row models; combining them throws.
  • Row reordering is disabled while a tree model is active.
  • Depth is capped at one hundred levels to catch cycles.

Row reordering

Move rows through a controlled callback without hidden data mutation.

Row reordering
Amara Okafor
Engineering
Europe
$4,501
Jonas Lindqvist
Design
Americas
$12,420
Mei Tanaka
Operations
Asia
$20,339
Rafael Duarte
Growth
Europe
$28,258
Selin Aydın
Engineering
Americas
$36,177
Noah Brenner
Design
Asia
$44,096

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

	let rows = $state(people(6));
	const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);

	// The table reports a move; the application owns the order.
	function move(source: RowId, target: RowId) {
		const from = rows.findIndex((row) => row.id === source);
		const to = rows.findIndex((row) => row.id === target);
		if (from < 0 || to < 0) return;
		const next = [...rows];
		const [moved] = next.splice(from, 1);
		next.splice(to, 0, moved!);
		rows = next;
	}
</script>

<DataTable data={rows} {columns} getRowId={(row) => row.id} onRowMove={move} pagination={false} label="Members: row reordering" />
With onRowMove set, every row gets accessible Move up and Move down buttons and becomes draggable. Both paths call the same callback with the source and target ids; the specimen splices a copy of the array. Moving is disabled while the rows are sorted, filtered, searched, grouped or nested.
  • The three drag listeners live on the tbody; their number does not grow with the row count.
  • The button labels come from the moveUp and moveDown messages.

Row events

Handle click, double-click, context menu and declarative cell actions.

Row events
Actions
Amara Okafor
Engineering
$4,501
Jonas Lindqvist
Design
$12,420
Mei Tanaka
Operations
$20,339
Rafael Duarte
Growth
$28,258
Selin Aydın
Engineering
$36,177
Noah Brenner
Design
$44,096

RowEvents.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(6);
	const columns: Column<Person>[] = [
		...pick(memberColumns(), ['name', 'team', 'revenue']),
		{ id: 'actions', header: 'Actions', width: 120, sortable: false, cell: actions }
	];
	let notice = $state('');
</script>

<!-- Elements carrying data-action are delegated to onAction; other clicks reach onRowClick. -->
{#snippet actions()}
	<button type="button" data-slean="button" data-variant="ghost" data-size="sm" data-action="archive">
		Archive
	</button>
{/snippet}

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	onRowClick={(row) => (notice = `Clicked ${row.name}`)}
	onRowDoubleClick={(row) => (notice = `Double-clicked ${row.name}`)}
	onRowContextMenu={(row, event) => {
		event.preventDefault();
		notice = `Context menu on ${row.name}`;
	}}
	onAction={(action, row) => (notice = `${action}: ${row.name}`)}
	pagination={false}
	label="Members: row events"
/>
<p role="status">{notice}</p>
onRowClick runs for clicks outside controls, so a control inside a cell does not also count as a row click. Each row is a Tab stop; Enter or Space on the focused row runs onRowClick as well. The Archive button is the Button primitive with a data-action attribute; the table delegates it to onAction with the action name, the row and the element.
  • Clicks on button, a, input, select, textarea, label, summary, an element with contenteditable, role="menuitem" or data-action, and anything inside an element marked data-no-row-click (a copy-to-clipboard span, a tooltip trigger) are not row clicks.
  • With onRowClick, rows get tabindex="0", a focus ring, and answer Enter and Space when the row itself has focus. A table with cell navigation (onEdit, onCellRangeChange) keeps its single Tab stop in the cells instead.
  • The context-menu callback receives the native event; the table does not prevent the default.
  • A right-click menu would be a Menu primitive opened by the application; the menu contract does not implement context menus.

Row animation

Animate application-owned row insertion and removal while respecting reduced motion.

Row animation
Amara Okafor
Engineering
Europe
$4,501
Jonas Lindqvist
Design
Americas
$12,420
Mei Tanaka
Operations
Asia
$20,339
Rafael Duarte
Growth
Europe
$28,258

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

	let rows = $state(people(4));
	const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);
	let entering = $state<RowId | null>(null);
	let leaving = $state<RowId | null>(null);

	function add() {
		const id = Math.max(0, ...rows.map((row) => row.id)) + 1;
		entering = id;
		rows = [{ ...people(1)[0]!, id, name: `New member ${id}` }, ...rows];
		setTimeout(() => (entering = null), 350);
	}
	function remove() {
		const first = rows[0];
		if (!first) return;
		leaving = first.id;
		setTimeout(() => {
			rows = rows.filter((row) => row.id !== first.id);
			leaving = null;
		}, 220);
	}
</script>

<button type="button" data-slean="button" data-variant="outline" onclick={add}>Add row</button>
<button type="button" data-slean="button" data-variant="outline" onclick={remove}>Remove first</button>

<DataTable
	data={rows}
	{columns}
	getRowId={(row) => row.id}
	rowClass={(row) => (row.id === entering ? 'enter' : row.id === leaving ? 'leave' : '')}
	pagination={false}
	label="Members: row animation"
/>

<style>
	/* The table adds the class; the application owns the motion and its reduced-motion rule. */
	:global(tr.enter td) { animation: enter 300ms var(--ease); }
	:global(tr.leave td) { animation: leave 200ms var(--ease) forwards; }
	@keyframes enter { from { opacity: 0; background: var(--highlight); } }
	@keyframes leave { to { opacity: 0; } }
	@media (prefers-reduced-motion: reduce) {
		:global(tr.enter td), :global(tr.leave td) { animation: none; }
	}
</style>
rowClass adds a class to a row; the keyframes, their duration and the prefers-reduced-motion rule are application CSS. The table has no animation of its own.

API

NameOfTypeDefaultDescription
getRowIdData and identity(row: T) => RowId–Returns a finite number or a string that stays stable across renders; selection, expansion, pinning and edits are keyed by it. Duplicates throw.
rowDetailsExpansionSnippet<[T, RowDetailsContext]>undefinedRenders a full-width details row under an expanded row. The second argument carries rowId, index and collapse(). A DataTable inside it keeps its own styles and keyboard behavior.
rowExpandableExpansion(row: T) => boolean() => trueWhether a row may show details.
expandColumnExpansionbooleanfalseRenders the expand toggle in its own narrow leading column (after selection) instead of the first data column.
rowLabelControls(row: T) => stringthe row idAccessible name of a row in its selection, expand and move controls.
RowDetailsContextRow model{ rowId: RowId; index: number; collapse: () => void }–Second argument of the rowDetails snippet.
expandRowByClickExpansionbooleanfalseToggles expansion when a non-interactive part of a cell is clicked.
onExpandExpansion(expanded: boolean, row: T) => voidundefinedRuns when a data row is expanded or collapsed.
onExpandedRowsChangeExpansion(rows: RowId[]) => voidundefinedRuns with the complete next expanded id list, group rows included.
getSubRowsData and identity(row: T) => readonly T[] | undefinedundefinedChild rows for tree tables. Not combinable with groupBy().
indentSizeExpansionnumber18Pixels added to the first cell per tree depth.
onRowMoveEvents and composition(source: RowId, target: RowId) => voidundefinedEnables the move buttons and drag and drop while no sort, filter, search, tree or custom row model is active.
onRowClickEvents and composition(row: T, event: MouseEvent | KeyboardEvent, index: number) => voidundefinedRuns for clicks outside controls (button, a, input, select, textarea, label, summary, contenteditable, menuitem, [data-action], [data-no-row-click]). Rows become Tab stops answering Enter and Space unless the table has cell navigation.
onRowDoubleClickEvents and composition(row: T, event: MouseEvent) => voidundefinedRuns on a row double-click.
onRowContextMenuEvents and composition(row: T, event: MouseEvent) => voidundefinedRuns on the contextmenu event of a row; the table does not prevent the default.
onActionEvents and composition(action: string, row: T, trigger: HTMLElement, index: number) => voidundefinedDelegated click on any element inside a cell that carries data-action.
rowClassEvents and composition(row: T, index: number) => stringundefinedClass of a row, for application state or animation. A class that sets --slean-table-row-bg tints every cell of the row, pinned cells included.
expandedTableStateRowId[][]Expanded details, tree and group row ids.
moveUpTableMessagesstring'Move up'Row reorder button.
moveDownTableMessagesstring'Move down'Row reorder button.