Table Structure
Rows
A row is identified, never mutated. Expansion, hierarchy, reordering, events and animation all build on getRowId and leave the data with the application.
- Fixture
- Benchmark
On this page
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.
// 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} // wrongThe 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.
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 |
<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>onExpandreports one row;onExpandedRowsChangethe 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 callscollapse(). expandColumnmoves 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.
| Details | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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 | |||||||||||||||||
<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"
/>- The stylesheet reaches a table's rows through child combinators from its own root, and the row
variable
--slean-table-row-bgis 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.
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 |
<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"
/>- 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.
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 |
<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" />- The three drag listeners live on the
tbody; their number does not grow with the row count. - The button labels come from the
moveUpandmoveDownmessages.
Row events
Handle click, double-click, context menu and declarative cell actions.
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 |
<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>- Clicks on
button,a,input,select,textarea,label,summary, an element withcontenteditable,role="menuitem"ordata-action, and anything inside an element markeddata-no-row-click(a copy-to-clipboard span, a tooltip trigger) are not row clicks. - With
onRowClick, rows gettabindex="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.
Amara Okafor | Engineering | Europe | $4,501 |
Jonas Lindqvist | Design | Americas | $12,420 |
Mei Tanaka | Operations | Asia | $20,339 |
Rafael Duarte | Growth | Europe | $28,258 |
<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>API
| Name | Of | Type | Default | Description |
|---|---|---|---|---|
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. |
rowDetails | Expansion | Snippet<[T, RowDetailsContext]> | undefined | Renders 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. |
rowExpandable | Expansion | (row: T) => boolean | () => true | Whether a row may show details. |
expandColumn | Expansion | boolean | false | Renders the expand toggle in its own narrow leading column (after selection) instead of the first data column. |
rowLabel | Controls | (row: T) => string | the row id | Accessible name of a row in its selection, expand and move controls. |
RowDetailsContext | Row model | { rowId: RowId; index: number; collapse: () => void } | – | Second argument of the rowDetails snippet. |
expandRowByClick | Expansion | boolean | false | Toggles expansion when a non-interactive part of a cell is clicked. |
onExpand | Expansion | (expanded: boolean, row: T) => void | undefined | Runs when a data row is expanded or collapsed. |
onExpandedRowsChange | Expansion | (rows: RowId[]) => void | undefined | Runs with the complete next expanded id list, group rows included. |
getSubRows | Data and identity | (row: T) => readonly T[] | undefined | undefined | Child rows for tree tables. Not combinable with groupBy(). |
indentSize | Expansion | number | 18 | Pixels added to the first cell per tree depth. |
onRowMove | Events and composition | (source: RowId, target: RowId) => void | undefined | Enables the move buttons and drag and drop while no sort, filter, search, tree or custom row model is active. |
onRowClick | Events and composition | (row: T, event: MouseEvent | KeyboardEvent, index: number) => void | undefined | Runs 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. |
onRowDoubleClick | Events and composition | (row: T, event: MouseEvent) => void | undefined | Runs on a row double-click. |
onRowContextMenu | Events and composition | (row: T, event: MouseEvent) => void | undefined | Runs on the contextmenu event of a row; the table does not prevent the default. |
onAction | Events and composition | (action: string, row: T, trigger: HTMLElement, index: number) => void | undefined | Delegated click on any element inside a cell that carries data-action. |
rowClass | Events and composition | (row: T, index: number) => string | undefined | Class 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. |
expanded | TableState | RowId[] | [] | Expanded details, tree and group row ids. |
moveUp | TableMessages | string | 'Move up' | Row reorder button. |
moveDown | TableMessages | string | 'Move down' | Row reorder button. |