sveltelean Table
Versionv0.2.0 GitHub

Install

@svelte-lean/table has one peer dependency, Svelte 5. It does not need @svelte-lean/vite, @svelte-lean/core or the primitives: the table is a Svelte component, not a behavior discovered from markup.

npm install @svelte-lean/table

TypeScript

The package is ESM only and resolves its types through exports. It type-checks under "moduleResolution": "bundler" (the SvelteKit and Vite default) and under "node16" or "nodenext" from ESM: next to each component declaration the build writes a .d.svelte.ts file, which is how those resolvers find the types of an import that ends in .svelte. The legacy "node" (node10) resolver does not read exports and finds only the root entry; CommonJS require() is not supported. The package build runs attw --pack with the ESM-only profile and fails on a resolution error.

First render

data is the array of rows, columns the schema, and getRowId returns a stable identity per row. Identity matters before any feature is switched on: selection, expansion, pinning and edits are all keyed by it, and a duplicate id throws at render time instead of producing silent mismatches.

members.svelte
<script lang="ts">
	import { DataTable, type Column } from '@svelte-lean/table';
	import '@svelte-lean/table/style.css';

	type Member = { id: number; name: string; revenue: number };
	const data: Member[] = [
		{ id: 1, name: 'Amara Okafor', revenue: 1200 },
		{ id: 2, name: 'Jonas Lindqvist', revenue: 9119 }
	];
	const columns: Column<Member>[] = [
		{ id: 'name', header: 'Member' },
		{ id: 'revenue', header: 'Revenue', align: 'end' }
	];
</script>

<DataTable {data} {columns} getRowId={(row) => row.id} />

Rendered output

The component renders a native <table> with <colgroup>, <thead>, scope on header cells and aria-sort when a column is sorted. What follows is the server output of the example above, produced by render() from svelte/server while this page was prerendered, with Svelte's hydration comments removed and tags indented. Nothing here is hand-written.

rendered by svelte/server
<div class="slean-table " data-layout="table" data-ssr="" data-density="comfortable" data-striped="false" data-bordered="false" data-hoverable="true">
	<div class="slean-table-scroll" role="region" aria-label="Data table">
		<table aria-label="Data table" aria-busy="false" class="slean-table-sticky" style="table-layout: fixed;">
			<colgroup>
				<col data-column="name" style="width: 160px;"/>
				<col data-column="revenue" style="width: 160px;"/>
			</colgroup>
			<thead>
				<tr>
					<th scope="col" colspan="1" rowspan="1" data-column="name">
						<div class="slean-table-heading">
							<button type="button" class="slean-table-sort">
								Member
								<span class="slean-table-sort-icon">
									<svg class="slean-table-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
										<path d="M5 6l3-3 3 3M5 10l3 3 3-3">
									</path>
								</svg>
							</span>
						</button>
						<button type="button" class="slean-table-resize" aria-label="Resize: Member">
						</button>
					</div>
				</th>
				<th scope="col" colspan="1" rowspan="1" data-column="revenue" data-align="end">
					<div class="slean-table-heading">
						<button type="button" class="slean-table-sort">
							Revenue
							<span class="slean-table-sort-icon">
								<svg class="slean-table-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
									<path d="M5 6l3-3 3 3M5 10l3 3 3-3">
								</path>
							</svg>
						</span>
					</button>
					<button type="button" class="slean-table-resize" aria-label="Resize: Revenue">
					</button>
				</div>
			</th>
		</tr>
	</thead>
	<tbody>
		<tr data-row-id="1" data-selected="false" draggable="false">
			<td data-cell="" data-column="name">
				<div class="slean-table-cell">
					Amara Okafor
				</div>
			</td>
			<td data-cell="" data-column="revenue" data-align="end">
				<div class="slean-table-cell">
					1200
				</div>
			</td>
		</tr>
		<tr data-row-id="2" data-last="" data-selected="false" draggable="false">
			<td data-cell="" data-column="name">
				<div class="slean-table-cell">
					Jonas Lindqvist
				</div>
			</td>
			<td data-cell="" data-column="revenue" data-align="end">
				<div class="slean-table-cell">
					9119
				</div>
			</td>
		</tr>
	</tbody>
</table>
</div>
</div>

The wrapper carries the slean-table class and the presentation props as data-* attributes; the scroll region is a labelled region; the header buttons sort; the cells carry data-row and data-column so keyboard navigation and range selection find them by structure, not by counting.

Styles

The stylesheet is optional and imported once. It styles the slean-table- classes and reads the --slean-* tokens of @svelte-lean/styles when they are defined, with neutral fallbacks otherwise, so a table looks right on its own and follows a theme when there is one. Its size is 6665 B gzip (5704 B brotli).

+layout.svelte or app.css
import '@svelte-lean/table/style.css';
app.css
/* Optional: the table reads @svelte-lean/styles tokens when they exist. */
@import '@svelte-lean/styles/tokens.css';

/* Or retint one table through its own properties. */
.slean-table {
	--slean-table-accent: var(--brand);
	--slean-table-radius: 0;
}

Basic table

The smallest useful table: typed data, typed columns and stable row identity.

Basic table
Members ledger
Members by team and region
Amara Okafor
Engineering
Europe
$4,501
2026-01-07
Jonas Lindqvist
Design
Americas
$12,420
2026-06-18
Mei Tanaka
Operations
Asia
$20,339
2026-02-01
Rafael Duarte
Growth
Europe
$28,258
2026-07-12
Selin Aydın
Engineering
Americas
$36,177
2026-03-23
Noah Brenner
Design
Asia
$44,096
2026-08-06

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

	const data = people(6);
	const columns: Column<Person>[] = [
		{ id: 'name', header: 'Member' },
		{ id: 'team', header: 'Team' },
		{ id: 'region', header: 'Region' },
		{ id: 'revenue', header: 'Revenue', align: 'end', format: (v) => money.format(Number(v)) },
		{ id: 'joined', header: 'Joined' }
	];
</script>

{#snippet title()}<span>Members ledger</span>{/snippet}
{#snippet footer()}<p class="footer">Six of the sample rows, rendered as one page.</p>{/snippet}

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	label="Members"
	caption="Members by team and region"
	tableLayout="auto"
	pagination={false}
	{title}
	{footer}
/>
A caption, a title snippet above the toolbar and a footer snippet below the pager are all optional; label names the table for assistive technology.
  • tableLayout="auto" lets the browser size the columns from their content.
  • pagination={false} renders the whole row model on one page.
  • Every header is a sort button by default; set sortable: false to opt out.

State

Every interaction changes one plain object, TableState: sorting, filters, search, page, selection, expansion, hidden columns, order, widths and pinning. Bind it to own it, or listen with onStateChange and pass the state back in. The table stores state only when the caller does not.

bind:state
<script lang="ts">
	import { DataTable, defaultState, type TableState } from '@svelte-lean/table';

	// One plain object. Bind it, persist it, or replace it from the outside.
	let tableState = $state<Partial<TableState>>(defaultState({ pageSize: 10 }));
</script>

<DataTable {data} {columns} getRowId={(row) => row.id} bind:state={tableState} />

<pre>{JSON.stringify(tableState.sorting)}</pre>
controlled from the outside
<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	state={query.state}
	onStateChange={(next) => query.setState(next)}
/>

What ships

The renderer entry measures 20031 B gzip (17886 B brotli) bundled on its own, Svelte's runtime excluded. A production Vite build that imports only DataTable and the stylesheet ships a Svelte Lean chunk of 16829 B brotli and no grouping, pivot, clipboard, export, server or state module, asserted by the table-basic fixture. The overview lists every entry point.

API

NameOfTypeDefaultDescription
dataData and identityreadonly T[]–Rows of the current client or server page.
columnsData and identityreadonly Column<T>[]–Typed column schema; nested children produce grouped headers.
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.
labelPresentationstring'Data table'Accessible name of the scroll region and the <table>.
captionPresentationstringundefinedVisible native <caption>.
titlePresentationSnippet<[RowModel<T>]>undefinedContent above the toolbar with the current row model.
footerEvents and compositionSnippet<[RowModel<T>]>undefinedContent after the pager.
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.
paginationControlled statebooleantrueShows the pager and slices pages. false renders the whole row model on one page.
stateControlled statePartial<TableState>{}Sorting, filters, search, paging, selection, expansion and column layout. Missing fields take the defaultState() values. Bindable.
onStateChangeControlled state(state: TableState) => voidundefinedRuns after every table-originated change with the complete next state.
idData and contentstring–Stable identity used by state fields and by every helper.
headerData and contentstring–Header text and the label of its controls.
alignLayout'start' | 'center' | 'end''start'Logical text alignment.
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.
defaultStateRoot entry(patch?: Partial<TableState>) => TableState–A complete state object with optional overrides.