sveltelean Table
Versionv0.2.0 GitHub

The capability levels are Table (semantic rendering), DataTable (sorting, filtering, selection, pagination), Virtual Table (large row counts, not shipped) and Data Grid (cell focus, arrow-key navigation, editing, pinning, range selection, copy and paste, grouping and tree). Nothing in the grid level is a separate runtime: the renderer already contains the keyboard model and enables it when onEdit or onCellRangeChange is set. Without them, cells carry no tabindex and the table is a table.

The output stays a native <table>. There is no role="grid" emulation over divs: header cells keep scope, sorted columns keep aria-sort, and assistive technology reads the table as a table. The grid keyboard pattern of the ARIA Authoring Practices is applied to the body: one Tab stop, arrow keys inside.

a cell in grid mode
<!-- A body cell in grid mode, as rendered: focusable by structure, not by role="grid". -->
<td data-cell data-column="price" tabindex="-1" style="text-align:end;">
	<div class="slean-table-cell">
		12.00
		<button type="button" class="slean-table-edit" tabindex="-1" aria-label="Edit: Price">…</button>
	</div>
</td>

What exists today

FeatureStatusHowSpecimen
Cell focus (roving tabindex)implementedSwitched on by onEdit or onCellRangeChange; one cell carries tabindex 0, the rest -1; the body is one Tab stop.Editing
Arrow-key navigationimplementedRow-based: Up and Down find the cell with the same data-column in the neighbouring row, so spans and detail rows do not shift the target. Mirrored under dir=rtl.Columns
Inline editingimplementedEnter or F2 on a cell; native text, number, date and select editors; parse, validate, onEdit.Editing
Range selectionimplementedClick, Shift+click and Shift+arrow through cellRange and onCellRangeChange; highlighted with data-range.Clipboard & Export
Copy and pasteimplementedcopyRange() and pasteRange() in the clipboard entry; the key binding is the application’s.Clipboard & Export
Pinned columnsimplementedpin on a column or state.pinning; sticky cells with logical insets.Columns
Grouping and tree rowsimplementedgroupBy() row model; getSubRows for hierarchy; expansion in the state.Grouping & Pivot
Sticky header and scrolling surfaceimplementedheight limits the region; the header stays in view; scrollTo() reaches a row.Layout
Virtual rowsnot implementedEvery row stays in the document. A windowed mode is a future opt-in (@svelte-lean/virtual), extracted only when a reusable need is proved.–
Home, End, Page Up, Page Down in the bodynot implementedOnly the four arrow keys, Enter, F2 and Escape are handled in cells.–
Keyboard navigation in the header rownot implementedHeader sort buttons and resize handles are ordinary Tab stops.–
Context menus and cell tooltipsnot implementedCell content that needs an overlay uses a primitive or an application singleton; the menu contract has no context-menu mode.–

Keyboard

With cell focus enabled. Outside grid mode the body has no keyboard model of its own.

KeyWhenResult
Tabbefore the bodyFocuses the last cell reached, or the first rendered data cell.
Tabon a cellLeaves the body to the next control after it (the pager); cells are one Tab stop.
ArrowLeft/ArrowRighton a cellPrevious or next data cell in the row; mirrored under dir=rtl.
ArrowUp/ArrowDownon a cellThe cell with the same column in the previous or next data row; detail rows and group rows are skipped.
Shift+Arrowwith onCellRangeChangeMoves and extends the range.
Enter/F2on an editable cellOpens the editor.
Enterin the editorSaves; focus returns to the cell.
Escapein the editorCancels; focus returns to the cell.

Composition

A grid is a composition of props and entry points. The example enables editing and ranges, pins the identity column, limits the height, and binds copy to the keyboard in the application, because which key copies is a product decision, not the table's.

inventory.svelte
<script lang="ts">
	import { DataTable, type CellRange, type EditEvent } from '@svelte-lean/table';
	import { applyEdit } from '@svelte-lean/table/editing';
	import { copyRange } from '@svelte-lean/table/clipboard';
	import '@svelte-lean/table/style.css';

	let rows = $state(load());
	let range = $state<CellRange | null>(null);
	// Editable columns, a pinned identity column, a wide scrolling middle.
	const columns = [
		{ id: 'sku', header: 'SKU', pin: 'left' as const, width: 120 },
		{ id: 'name', header: 'Product', editable: true, width: 240 },
		{ id: 'price', header: 'Price', align: 'end' as const, editable: true, editor: 'number' as const },
		{ id: 'stock', header: 'Stock', align: 'end' as const, editable: true, editor: 'number' as const }
	];
</script>

<!-- onEdit and onCellRangeChange switch the body to the grid pattern: one Tab stop, arrow keys, Enter/F2. -->
<DataTable
	data={rows}
	{columns}
	getRowId={(row) => row.sku}
	onEdit={(event: EditEvent<Product>) => (rows = applyEdit(rows, event, columns, (row) => row.sku))}
	cellRange={range}
	onCellRangeChange={(next) => (range = next)}
	stickyHeader
	height="32rem"
	pagination={false}
	label="Inventory"
/>

<svelte:document
	onkeydown={(event) => {
		if (range && event.key === 'c' && (event.metaKey || event.ctrlKey)) {
			navigator.clipboard.writeText(copyRange(range, rows, columns, (row) => row.sku));
		}
	}}
/>

Scale

Rows are not virtualized, so the cost of a large grid is the cost of its DOM. The numbers below are the medians the playground benchmark recorded for the whole table in the document, with the environment of that run; they are read from the benchmark file and say nothing about other hardware or other libraries. A missing file renders "not measured".

SubjectMedianp95Method
Mount, one thousand rows283.8 ms342.8 mscomponent init to the second animation frame after onMount, client render of the whole table (ssr=false); median of 5 loads
Sort by revenue, one thousand rows114.1 ms117.7 msheader button click (ascending by revenue) to the second animation frame; median of 5
Mount, ten thousand rows3907.1 ms5984.2 mscomponent init to the second animation frame after onMount, client render of the whole table (ssr=false); median of 5 loads
Sort by revenue, ten thousand rows1928.4 ms2142.4 msheader button click (ascending by revenue) to the second animation frame; median of 5

Environment: chromium 153.0.8010.53, Apple M2 (8 cores, 16 GiB), darwin 27.0.0, Node v22.23.3, production build, headless yes, run 2026-09-27. Reproduce with yarn workspace @svelte-lean/playground bench.

API

NameOfTypeDefaultDescription
onEditEvents and composition(event: EditEvent<T>) => void | Promise<void>undefinedReceives a parsed and validated cell edit; enables editing and cell focus. The table never mutates rows.
cellRangeEvents and compositionCellRange | nullundefinedThe highlighted rectangular range; with onCellRangeChange cells become focusable.
onCellRangeChangeEvents and composition(range: CellRange | null) => voidundefinedReports a click, Shift+click or Shift+arrow range.
heightPresentationstringundefinedmax-height of the scroll region; with it the region is a Tab stop.
stickyHeaderPresentationbooleantrueKeeps the <thead> in view. With height it sticks inside the scroll region; without, it sticks to the page while the table fits its width (offset by --slean-table-sticky-top). A table wider than its container scrolls sideways in the region, which captures sticky positioning: the header then scrolls with the rows unless the region also scrolls down.
pinLayout'left' | 'right' | falsefalseInitial sticky edge; state.pinning overrides it.
editableEditingboolean | ((row: T) => boolean)falseCell editing for the column or per row; needs onEdit on the table.
CellPositionEvents and positions{ row: RowId; column: string }–A cell by row id and column id.
CellRangeEvents and positions{ start: CellPosition; end: CellPosition }–An inclusive rectangle anchored by two positions.
@svelte-lean/table/clipboardEntry pointscopyRange · pasteRange · rangeBounds–A rectangular range to tab-separated text and back to validated EditEvents.
@svelte-lean/table/editingEntry pointsapplyEdit · setValue · parseEdit · createHistory · editEvent–Immutable edit application and a caller-owned undo history.