# Rows

> Row identity, expandable details, tree data with getSubRows, row reordering, row events and application-owned row animation.

Page: https://svelte-lean.vercel.app/table/rows · Markdown: https://svelte-lean.vercel.app/table/rows.md · Index: https://svelte-lean.vercel.app/llms.txt

A row is identified, never mutated. Expansion, hierarchy, reordering, events and animation all build on getRowId and leave the data with the application.

- **Source**: core.ts (prepareRows, flattenRows)data-table.svelte
- **Tests**: core.test.tsdata-table.dom.test.tstable.test.tstable.spec.ts (browser)
- **Fixture**: fixtures/table-basic
- **Benchmark**: table.bench.spec.ts

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: a press on a row grip and the keys on it reach one delegated listener per event type, and click callbacks resolve the row from the event target.

`row identity`

```ts
// 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} // wrong
```

The 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.

ExpandableRows.svelte

```svelte
<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>
```

A rowDetails snippet renders a full-width row under an expanded record. The toggle button carries aria-expanded; expandRowByClick also toggles on a click anywhere in a cell that is not a control. Invited members return false from rowExpandable and get no toggle.

- `onExpand` reports one row; `onExpandedRowsChange` the 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 calls `collapse()`.
- `expandColumn` moves 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.

NestedTable.svelte

```svelte
<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="text" data-color="neutral" data-size="small" 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"
/>
```

Each member expands into a compact, striped DataTable of invoices. The toggle has its own column (expandColumn) and is named after the member (rowLabel: Expand row: Amara Okafor). The outer table's hover, density and borders stop at the details row, and its keyboard, drag and scrollTo() never reach the nested rows.

- The stylesheet reaches a table's rows through child combinators from its own root, and the row variable `--slean-table-row-bg` is 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.

TreeData.svelte

```svelte
<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"
/>
```

getSubRows returns the children of a row; the engine flattens the tree, indents the first cell by depth times indentSize, and keeps a parent visible when a descendant matches a search or filter. The first parent starts expanded through state.expanded.

- 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.

RowReordering.svelte

```svelte
<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 (a grip dragged, or moved with the arrow keys);
	// 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" />
```

With onRowMove set, a narrow leading column holds a grip on every row. Drag a grip: the row follows the pointer, the rows it passes slide out of its way, and the place it will take is open before you let go. On a focused grip, the arrow keys move the row one place, Home and End to the first or last place. Every move calls the same callback with the source and target ids; the specimen splices a copy of the array.

- The leading control columns come in one order: expand, grip (`slean-table-mover`), selection. The grip column sticks at the start edge like the others; its width is `--slean-table-move-width` (36px).
- A drag starts after the pointer has moved 3px, so a click on a grip moves nothing. The lifted row and its open details row move together, opaque and shadowed (`--slean-table-row-lifted-shadow`, `--slean-table-row-lifted` for its background) and below the sticky header. Escape cancels; every row slides back.
- Held near the top or bottom edge of the scrolling container (the height-limited table region, or the nearest scrolling ancestor, or the page), the container scrolls, faster toward the edge; the wheel works during a drag as well.
- On release the table calls `onRowMove`, waits for the new rows and lets the row glide from where it was dropped into its new place. A move the application does not make slides back.
- Moves stay within the current page. While the rows are sorted, filtered or searched the grips are hidden and the column stays, so a sort does not shift the columns; a tree (`getSubRows`) or a custom row model (grouping, pivot) has no grip column.
- Pointer moves write transforms on the row elements and nothing else: no state changes until the drop, and the listeners of a drag exist only while it lasts. The grip label comes from the `moveRow` message (Drag to reorder: followed by `rowLabel` or the id).

## Row events

Handle click, double-click, context menu and declarative cell actions.

RowEvents.svelte

```svelte
<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="text" data-color="neutral" data-size="small" 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>
```

onRowClick runs for clicks outside controls, so a control inside a cell does not also count as a row click. Each row is a Tab stop; Enter or Space on the focused row runs onRowClick as well. The Archive button is the Button primitive with a data-action attribute; the table delegates it to onAction with the action name, the row and the element.

- Clicks on `button`, `a`, `input`, `select`, `textarea`, `label`, `summary`, an element with `contenteditable`, `role="menuitem"` or `data-action`, and anything inside an element marked `data-no-row-click` (a copy-to-clipboard span, a tooltip trigger) are not row clicks.
- With `onRowClick`, rows get `tabindex="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 is the menu primitive's context-menu trigger: `data-slean-contextmenu="<menu id>"` on a focusable element inside the cell opens the menu at the pointer ([Menu](https://svelte-lean.vercel.app/primitives/menu.md)).

## Row animation

Animate application-owned row insertion and removal while respecting reduced motion.

RowAnimation.svelte

```svelte
<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="outlined" data-color="neutral" onclick={add}>Add row</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" 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>
```

rowClass adds a class to a row; the keyframes, their duration and the prefers-reduced-motion rule are application CSS. The table has no animation of its own.

## API

| Name | Of | Type | Default | Description |
| --- | --- | --- | --- | --- |
| [`getRowId`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-rowexpandable) | Expansion | `(row: T) => boolean` | `() => true` | Whether a row may show details. |
| [`expandColumn`](https://svelte-lean.vercel.app/table/api.md#prop-expandcolumn) | Expansion | `boolean` | `false` | Renders the expand toggle in its own narrow leading column (after selection) instead of the first data column. |
| [`rowLabel`](https://svelte-lean.vercel.app/table/api.md#prop-rowlabel) | Controls | `(row: T) => string` | `the row id` | Accessible name of a row in its selection, expand and move controls. |
| [`RowDetailsContext`](https://svelte-lean.vercel.app/table/api.md#type-rowdetailscontext) | Row model | `{ rowId: RowId; index: number; collapse: () => void }` | – | Second argument of the rowDetails snippet. |
| [`expandRowByClick`](https://svelte-lean.vercel.app/table/api.md#prop-expandrowbyclick) | Expansion | `boolean` | `false` | Toggles expansion when a non-interactive part of a cell is clicked. |
| [`onExpand`](https://svelte-lean.vercel.app/table/api.md#prop-onexpand) | Expansion | `(expanded: boolean, row: T) => void` | `undefined` | Runs when a data row is expanded or collapsed. |
| [`onExpandedRowsChange`](https://svelte-lean.vercel.app/table/api.md#prop-onexpandedrowschange) | Expansion | `(rows: RowId[]) => void` | `undefined` | Runs with the complete next expanded id list, group rows included. |
| [`getSubRows`](https://svelte-lean.vercel.app/table/api.md#prop-getsubrows) | Data and identity | `(row: T) => readonly T[] \| undefined` | `undefined` | Child rows for tree tables. Not combinable with groupBy(). |
| [`indentSize`](https://svelte-lean.vercel.app/table/api.md#prop-indentsize) | Expansion | `number` | `18` | Pixels added to the first cell per tree depth. |
| [`onRowMove`](https://svelte-lean.vercel.app/table/api.md#prop-onrowmove) | Events and composition | `(source: RowId, target: RowId) => void` | `undefined` | Adds a leading grip column. A grip is dragged live (the row follows the pointer, the others slide aside, the container scrolls near its edges, Escape cancels) or moved with ArrowUp, ArrowDown, Home and End. Moves stay within the page; the grips are hidden while sorted, filtered or searched, and there is no grip column with a tree or a custom row model. |
| [`onRowClick`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-onrowdoubleclick) | Events and composition | `(row: T, event: MouseEvent) => void` | `undefined` | Runs on a row double-click. |
| [`onRowContextMenu`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#state-expanded) | TableState | `RowId[]` | `[]` | Expanded details, tree and group row ids. |
| [`moveRow`](https://svelte-lean.vercel.app/table/api.md#message-moverow) | TableMessages | `string` | `'Drag to reorder'` | Hidden header text of the grip column, and prefix of each grip label (followed by the row name). |

## Related

- [Grouping & Pivot](https://svelte-lean.vercel.app/table/grouping-pivot.md)
- [Selection](https://svelte-lean.vercel.app/table/selection.md)
- [Menu](https://svelte-lean.vercel.app/primitives/menu.md)
