sveltelean Table
Versionv0.2.0 GitHub

The default comparator handles numbers, dates and text: numbers by value, dates by time, text through an Intl.Collator built with the locale prop and numeric collation, so "item 10" sorts after "item 9". Empty values (null, undefined, an empty string or NaN) sort last in both directions. A column supplies compare when values have a domain order, and the direction is applied to its result.

Because sorting is state, it can be set before the first render, persisted, or driven from outside the table. A sort change resets the page index so the user is not left on an empty page. The sorted header carries aria-sort; with more than one rule, the precedence number is exposed to assistive technology after the sortOrder message.

Single and multi-column sort

Single and multi-column sorting with visible priority and controlled state.

Sorting
Joined
Noah Brenner
Active
Design
Asia
$44,096
85%
2026-08-06
Lucas Ferreira
Invited
Growth
Asia
$43,610
82%
2026-02-16
Selin Aydın
Active
Engineering
Americas
$36,177
72%
2026-03-23
İpek Yılmaz
Away
Operations
Americas
$35,691
69%
2026-06-05
Rafael Duarte
Invited
Growth
Europe
$28,258
59%
2026-07-12
Ömer Say
Active
Design
Europe
$27,772
56%
2026-01-22
Mei Tanaka
Away
Operations
Asia
$20,339
46%
2026-02-01
Hana Kowalska
Active
Engineering
Asia
$19,853
43%
2026-05-11
Jonas Lindqvist
Active
Design
Americas
$12,420
33%
2026-06-18
Elias Moreau
Invited
Growth
Americas
$11,934
30%
2026-09-28
Amara Okafor
Active
Engineering
Europe
$4,501
20%
2026-01-07
Priya Raman
Away
Operations
Europe
$4,015
98%
2026-04-17
[{"id":"revenue","desc":true}]

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

	const data = people(12);
	const ORDER = ['Active', 'Away', 'Invited'];
	const columns: Column<Person>[] = memberColumns().map((column) =>
		column.id === 'status'
			? { ...column, compare: (a, b) => ORDER.indexOf(String(a)) - ORDER.indexOf(String(b)) }
			: column.id === 'joined'
				? { ...column, sortable: false }
				: column
	);
	let tableState = $state<Partial<TableState>>(defaultState({ sorting: [{ id: 'revenue', desc: true }] }));
</script>

<!-- Click a header to cycle none, ascending, descending; Shift+click adds a second column. -->
<DataTable {data} {columns} getRowId={(row) => row.id} bind:state={tableState} pagination={false} label="Members" />

<pre>{JSON.stringify(tableState.sorting)}</pre>
The table opens sorted by revenue, descending, from the initial state. Status sorts by a custom comparator in the order Active, Away, Invited rather than alphabetically; Joined has sortable: false and renders as plain text. The pre element prints state.sorting.
  • Shift+click adds or cycles a column without clearing the others; a plain click replaces the list.
  • In manual mode the engine applies no sorting; the state is sent to the server as it is.
  • With groupBy(), rows stay sorted inside each group and groups appear in the order of their first sorted row.

Custom sort controls

A header can host its own sort control. headerCell receives a HeaderContext: the column, its current sort, the whole sorting list, toggleSort() (the column's own cycle) and setSorting(), which replaces the list with any sort ids and resets the page. With sortable: false the header renders no sort button, so the snippet's controls are not nested in one. sortKeys lists the further ids that count as this column's sort, so aria-sort and the context's sort follow whichever of them is active.

Custom sort controls
Activity
Amara Okafor
Engineering
$4,501
2026-01-07 · 20%
Jonas Lindqvist
Design
$12,420
2026-06-18 · 33%
Mei Tanaka
Operations
$20,339
2026-02-01 · 46%
Rafael Duarte
Growth
$28,258
2026-07-12 · 59%
Selin Aydın
Engineering
$36,177
2026-03-23 · 72%
Noah Brenner
Design
$44,096
2026-08-06 · 85%
Priya Raman
Operations
$4,015
2026-04-17 · 98%
Elias Moreau
Growth
$11,934
2026-09-28 · 30%
Hana Kowalska
Engineering
$19,853
2026-05-11 · 43%
Ömer Say
Design
$27,772
2026-01-22 · 56%
İpek Yılmaz
Operations
$35,691
2026-06-05 · 69%
Lucas Ferreira
Growth
$43,610
2026-02-16 · 82%
[]

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

	const data = people(12);
	const FIELDS = [
		{ id: 'joined', label: 'Joined date' },
		{ id: 'progress', label: 'Progress' }
	];
	const columns: Column<Person>[] = [
		...pick(memberColumns(), ['name', 'team']),
		{ ...memberColumns().find((c) => c.id === 'revenue')!, sortDescFirst: true },
		{
			id: 'activity',
			header: 'Activity',
			accessor: (row) => `${row.joined} · ${row.progress}%`,
			width: 190,
			sortable: false,
			sortKeys: FIELDS.map((field) => field.id),
			headerCell: activity
		},
		// Hidden columns give the client row model values to sort the two keys by.
		{ id: 'joined', header: 'Joined' },
		{ id: 'progress', header: 'Progress' }
	];
	let tableState = $state<Partial<TableState>>({ hidden: ['joined', 'progress'] });
</script>

{#snippet activity({ column, sort, setSorting }: HeaderContext<Person>)}
	<span class="head">
		{column.header}
		<button
			type="button"
			class="trigger"
			popovertarget="activity-sort"
			aria-label="Sort {column.header}"
			data-active={sort ? '' : undefined}
		>
			<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"
				><path d="M2.5 4h11M4.5 8h7M6.5 12h3" /></svg
			>
		</button>
	</span>
	<div id="activity-sort" popover data-slean="popover" data-slean-side="bottom" data-slean-align="end">
		<label>
			Order by
			<select
				value={sort?.id ?? ''}
				onchange={(e) =>
					setSorting(
						e.currentTarget.value ? [{ id: e.currentTarget.value, desc: sort?.desc ?? true }] : []
					)}
			>
				<option value="">Unsorted</option>
				{#each FIELDS as field (field.id)}<option value={field.id}>{field.label}</option>{/each}
			</select>
		</label>
		<label>
			Direction
			<select
				value={sort?.desc === false ? 'asc' : 'desc'}
				disabled={!sort}
				onchange={(e) =>
					sort && setSorting([{ id: sort.id, desc: e.currentTarget.value === 'desc' }])}
			>
				<option value="desc">Newest or highest first</option>
				<option value="asc">Oldest or lowest first</option>
			</select>
		</label>
	</div>
{/snippet}

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	bind:state={tableState}
	pagination={false}
	label="Members: custom sort control"
/>
<pre class="state" aria-label="Sort state">{JSON.stringify(tableState.sorting ?? [])}</pre>
Activity is not sortable itself: its header opens a native popover with two selects that call setSorting() with joined or progress, either direction. Both ids are the column's sortKeys, so the Activity header carries aria-sort. Two hidden columns give the client model the values to sort by; in manual mode the ids go to the server as they are. Revenue has sortDescFirst: its first click sorts descending.
  • sortDescFirst suits amounts, dates and counts, where the largest or newest value is the one users look for first.
  • A sort id that names no column is kept in the state and ignored by the client row model; declare a hidden column with that id to sort by it on the client.

Keyboard

KeyWhenResult
Tabin the headerMoves between the sort buttons and resize handles.
Enter/Spaceon a sort buttonCycles the column: ascending, descending, none (descending first with sortDescFirst).
Shift+Enter/Shift+Spaceon a sort buttonCycles the column while keeping the other sorted columns.

Engine

The renderer calls table.toggleSort(id, multi); the pure function underneath is exported for application code that builds its own controls.

toggleSort
import { toggleSort } from '@svelte-lean/table';

// none → ascending → descending → none; multi keeps the other rules.
toggleSort([], 'revenue'); // [{ id: 'revenue', desc: false }]
toggleSort([{ id: 'revenue', desc: false }], 'revenue'); // [{ id: 'revenue', desc: true }]
toggleSort([{ id: 'revenue', desc: true }], 'revenue'); // []
toggleSort([{ id: 'team', desc: false }], 'revenue', true); // [{ id: 'team', desc: false }, { id: 'revenue', desc: false }]
// descFirst (a column with sortDescFirst): none → descending → ascending → none.
toggleSort([], 'revenue', false, true); // [{ id: 'revenue', desc: true }]

API

NameOfTypeDefaultDescription
sortingTableStateSort[][]Ordered sort rules; the array order is the multi-sort precedence.
sortableSort, filter and aggregatebooleantrueHeader sorting; empty values sort last in both directions.
compareSort, filter and aggregate(a: unknown, b: unknown, aRow: T, bRow: T) => numbernumbers, dates, then a locale collatorCustom comparator; the sort direction is applied to its result.
sortDescFirstSort, filter and aggregatebooleanfalseThe first activation sorts descending: descending, ascending, none.
sortKeysSort, filter and aggregatestring[]undefinedFurther sort ids that count as this column sorted (aria-sort, the icon), for a headerCell that sorts by several fields.
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.
HeaderContext<T>Row model{ column; sort: { id; desc; index } | undefined; sorting: Sort[]; toggleSort(multi?); setSorting(sorting) }–Argument of headerCell snippets.
localePresentationstringundefinedLocale of the collator used for sorting and of case folding in search and filters.
manualControlled statebooleanfalseTreats data as already sorted, filtered and paged by the application; the client model applies none of them.
sortOrderTableMessagesstring'Sort order'Read to assistive technology before the multi-sort precedence number.
toggleSortRoot entry(sorting: Sort[], id: string, multi?: boolean, descFirst?: boolean) => Sort[]–Pure sort cycle none, ascending, descending, none; descFirst starts descending.
toggleSortTable(id: string, multi?: boolean) => void–Cycles none, ascending, descending, none (descending first with sortDescFirst); multi keeps other sorted columns. Resets the page.
sortOfTable(column: string | Column<T>) => { id: string; desc: boolean; index: number } | undefined–Sort rule, direction and precedence of a column; given a column, a rule on one of its sortKeys counts.
SortEvents and positions{ id: string; desc: boolean }–One sort rule.