Table Start
Getting started
Install one package, describe the rows and the columns, and render a native table. Three props are required; every capability after that is a prop or a separate entry point.
- Fixture
- Benchmark
On this page
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/tablepnpm add @svelte-lean/tableyarn add @svelte-lean/tablebun add @svelte-lean/tableTypeScript
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.
<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.
<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).
import '@svelte-lean/table/style.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.
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 |
<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}
/>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: falseto 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.
<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><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
| Name | Of | Type | Default | Description |
|---|---|---|---|---|
data | Data and identity | readonly T[] | – | Rows of the current client or server page. |
columns | Data and identity | readonly Column<T>[] | – | Typed column schema; nested children produce grouped headers. |
getRowId | Data 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. |
label | Presentation | string | 'Data table' | Accessible name of the scroll region and the <table>. |
caption | Presentation | string | undefined | Visible native <caption>. |
title | Presentation | Snippet<[RowModel<T>]> | undefined | Content above the toolbar with the current row model. |
footer | Events and composition | Snippet<[RowModel<T>]> | undefined | Content after the pager. |
tableLayout | Presentation | '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. |
pagination | Controlled state | boolean | true | Shows the pager and slices pages. false renders the whole row model on one page. |
state | Controlled state | Partial<TableState> | {} | Sorting, filters, search, paging, selection, expansion and column layout. Missing fields take the defaultState() values. Bindable. |
onStateChange | Controlled state | (state: TableState) => void | undefined | Runs after every table-originated change with the complete next state. |
id | Data and content | string | – | Stable identity used by state fields and by every helper. |
header | Data and content | string | – | Header text and the label of its controls. |
align | Layout | 'start' | 'center' | 'end' | 'start' | Logical text alignment. |
format | Data and content | (value: unknown, row: T) => string | null | String(value) | Formats the plain text of a cell and of a CSV export without a snippet; null or an empty string renders the placeholder. |
defaultState | Root entry | (patch?: Partial<TableState>) => TableState | – | A complete state object with optional overrides. |