# Layout

> Table-level presentation: density, stripes, borders, sticky header, fixed height and scrolling surface, caption and label.

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

Table-level presentation is a set of props that change attributes on the root and a stylesheet that reads them. Density, stripes, borders, the sticky header and the scrolling surface never touch the row model.

- **Source**: data-table.sveltestyle.css
- **Tests**: style.test.tsdata-table.dom.test.tstable.test.tstable.spec.ts (browser)
- **Fixture**: fixtures/table-basic
- **Benchmark**: table.bench.spec.ts

The renderer writes `data-density`, `data-striped`, `data-bordered` and `data-hoverable` on its root and leaves the drawing to CSS. `style.css` is one file with neutral defaults; each color, radius and duration is a `--slean-table-*` custom property that falls back to a `--slean-*` token when `@svelte-lean/styles` is present. Nothing about presentation is computed in JavaScript, so switching density on a thousand rows is one attribute write.

Two props affect structure rather than paint. `height` limits the scroll region and makes it a keyboard Tab stop, because a region that scrolls must be reachable without a pointer. `tableLayout` chooses between the fixed algorithm, which applies the column widths, and the automatic one, which sizes columns from content.

## Density and borders

Switch density, stripes and cell borders without changing the row model.

DensityBorders.svelte

```svelte
<script lang="ts">
	import { DataTable } from '@svelte-lean/table';
	import '@svelte-lean/styles/radio-group.css';
	import '@svelte-lean/styles/checkbox.css';
	import { memberColumns } from '$lib/demo/columns';
	import { people } from '$lib/demo/data';

	const data = people(6);
	const columns = memberColumns();
	// Presentation props change attributes on the root; the row model is untouched.
	let density = $state<'compact' | 'comfortable' | 'spacious'>('comfortable');
	let striped = $state(false);
	let bordered = $state(true);
</script>

<fieldset data-slean="radio-group">
	<legend>Density</legend>
	{#each ['compact', 'comfortable', 'spacious'] as value (value)}
		<label><input type="radio" name="density" {value} bind:group={density} /> {value}</label>
	{/each}
</fieldset>
<label><input type="checkbox" data-slean="checkbox" bind:checked={striped} /> Stripes</label>
<label><input type="checkbox" data-slean="checkbox" bind:checked={bordered} /> Borders</label>

<DataTable {data} {columns} getRowId={(row) => row.id} {density} {striped} {bordered} pagination={false} label="Members: density and borders" />
```

The controls are Tier 0 primitives: a native radio group and two native checkboxes styled by @svelte-lean/styles, with no behavior JavaScript. Each change writes one attribute on the table root.

- Row hover is a fourth attribute, `data-hoverable`, on by default.
- Under `forced-colors: active` the stylesheet uses system colors for selection and ranges.

## Sticky header

Keep headers visible inside a constrained scrolling surface.

StickyHeader.svelte

```svelte
<script lang="ts">
	import { DataTable } from '@svelte-lean/table';
	import { memberColumns } from '$lib/demo/columns';
	import { people } from '$lib/demo/data';

	const data = people(36);
	const columns = memberColumns();
</script>

<!-- height limits the scroll region; the region becomes a Tab stop so keyboard users can scroll it. -->
<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	stickyHeader
	height="18rem"
	pagination={false}
	label="Members: sticky header"
	caption="All rows in one scrolling region"
/>
```

The thead is position: sticky inside the scroll region; with height set, the region carries tabindex=0 and an accessible name so it can be scrolled from the keyboard. Where sticky positioning is unsupported the header scrolls with the rows.

- `stickyHeader` is on by default. With `height` the header sticks inside the region. Without it, the header sticks to the page while the table fits its width: the region then stops being a scroll container (`data-page-sticky`), and `--slean-table-sticky-top` moves the header below a fixed application bar. Once the table is wider than its container the region scrolls sideways again; a region that scrolls sideways captures sticky positioning, so the header scrolls with the rows unless the region also scrolls down, and is then not sticky at all (a sticky box in a scroller is composited, and every row painted under it costs compositing work). A ResizeObserver switches between these.
- The scroll event is available through `onScroll`; `scrollTo()` scrolls to a row by id or index.

## Grid layout for long pages

A native table lays out every row to size its columns, and the browser cannot skip the rows it does not show. For pages of hundreds of rows, `layout="grid"` keeps the same elements and the same features but lays each row out as a CSS grid over one track list, with explicit ARIA roles (`table`, `rowgroup`, `row`, `cell`) so the semantics survive the changed display.

- Body rows stand in sections of 20, one `<tbody>` each, under `content-visibility: auto`: the browser styles, lays out and paints only the sections near the screen. Nothing is virtualized; every row stays in the document, so find-in-page, printing and assistive technology reach it.
- A skipped section assumes the height of the rows already drawn, so the page is about as tall before a section is drawn as after. Once a page is complete, sections are laid out ahead in idle time, nearest the screen first, one per idle period that has room for it, so a fast scroll mostly pays for paint and no input waits behind more than one section.
- Column widths are measured from the content, on hidden copies of the header and the rows, never on the rows on screen: the first rows when a page first renders; the rest of the page, a row whose cells change afterwards (text or children) and every row when a font finishes loading wait in one queue, measured when idle fifteen rows a pass, as many passes as an idle period has time for, so no pass holds the thread for long. Widths only grow while the columns stay the same; until a column widens, a body cell clips at its edge rather than drawing over the next one. A page that mounts progressively is measured with its longest rows too (by the length of their formatted values), drawn once into a hidden copy before the first frame, so a long value further down does not widen its column under the reader. A declared `width` is a minimum and `maxWidth` (default 480) caps a measured width; a capped column clips with an ellipsis. Spare width is shared in proportion to the content.
- `tableLayout` and cell `span` do not apply. Combine with `progressive` so a large page mounts in slices.

## Naming and structure

`label` names the region and the table for assistive technology and defaults to "Data table"; give every table a name that means something in the page. `caption` renders a visible native caption. `showHeader` removes the whole `thead` for lists that do not need column names, and `class` adds a hook on the root for application CSS.

`presentation props`

```svelte
<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	label="Invoices"
	caption="Open invoices, updated hourly"
	tableLayout="auto"
	showHeader={false}
	rowHoverable={false}
	class="invoices"
/>
```

`app.css`

```css
/* Retint one table; every property has a neutral fallback in style.css. */
.slean-table.invoices {
	--slean-table-primary: var(--brand);
	--slean-table-border: var(--hairline);
	--slean-table-radius: 0;
	--slean-table-selection-width: 36px;
}
```

## Styling

`style.css` is written for overriding. Every rule sits in the `slean.components` cascade layer of `@svelte-lean/styles` and every selector is wrapped in `:where()`, so any application rule outside a layer wins without `!important`, whatever its specificity. The generic control rules target the table's own parts (`.slean-table-button`, `.slean-table-input`), so a Button, an Input or any other primitive placed in a cell keeps its own look, and the selection checkboxes carry `data-slean="checkbox"`: with `@svelte-lean/styles/checkbox.css` loaded they are the Checkbox primitive, otherwise a native checkbox in the accent color.

Each token reads the matching `--slean-*` token when `@svelte-lean/styles` is present. Without it, the fallbacks are the same defaults, written with `light-dark()`: they follow the page's `color-scheme`, so a page that declares `color-scheme: dark` (or `light dark` under a dark system preference) gets a dark table with no extra CSS. Set tokens on `.slean-table` or on the class passed through `class`; every table resets them on its own root, so a table nested in a detail row does not inherit the outer table's overrides.

| Token | Default | Paints |
| --- | --- | --- |
| `--slean-table-bg` | `--slean-surface` | Root and cell background. |
| `--slean-table-fg` | `--slean-fg` | Text. |
| `--slean-table-muted` | `--slean-fg-muted` | Secondary text: status line, placeholder. |
| `--slean-table-border` | `--slean-border` | Outline, row lines, control borders. |
| `--slean-table-soft` | `--slean-muted` | Hover of controls, skeleton bars, group rows. |
| `--slean-table-primary` | `--slean-primary-main` | Checkbox fallback, resize handle, ranges. |
| `--slean-table-selected` | `--slean-primary-soft` | Cell ranges; default of row-selected. |
| `--slean-table-error` | `--slean-error-main` | Error alert and editor validation text. |
| `--slean-table-focus` | `--slean-focus-ring-color` | Focus rings of controls, rows and cells. |
| `--slean-table-shadow` | `--slean-elevation-4` | Column controls panel. |
| `--slean-table-radius` | `--slean-radius-lg` | Root corners. |
| `--slean-table-control-radius` | `--slean-radius-sm` | Buttons, inputs, selects. |
| `--slean-table-control-height` | `--slean-control-height-sm (2rem)` | Toolbar and pager controls. |
| `--slean-table-font` | `--slean-font-sans` | Font family. |
| `--slean-table-font-size` | `--slean-text-sm (0.875rem)` | Body text; 0.8125rem when compact. |
| `--slean-table-duration` | `--slean-duration-fast` | Transitions (reduced motion: none). |
| `--slean-table-cell-padding-x` | `0.75rem` | Horizontal cell padding. |
| `--slean-table-cell-padding-y` | `0.5rem` | Vertical cell padding; set by density. |
| `--slean-table-header-bg` | `soft mixed into bg` | Header, filter row, footer. |
| `--slean-table-header-fg` | `muted` | Header text. |
| `--slean-table-header-font-size` | `0.8125rem` | Header text size. |
| `--slean-table-header-font-weight` | `500` | Header weight. |
| `--slean-table-header-text-transform` | `none` | For example uppercase. |
| `--slean-table-header-letter-spacing` | `normal` | Tracking of uppercase headers. |
| `--slean-table-row-hover` | `soft mixed into bg` | Hovered row. |
| `--slean-table-row-stripe` | `soft mixed into bg` | Even rows when striped; detail rows. |
| `--slean-table-row-selected` | `selected` | Selected row. |
| `--slean-table-row-bg` | `bg` | Per row: what every cell of the row paints. |
| `--slean-table-row-lifted` | `bg` | A row being dragged (onRowMove). |
| `--slean-table-row-lifted-shadow` | `--slean-elevation-3` | Shadow of a row being dragged. |
| `--slean-table-pin-shadow` | `black at 6% (22% dark)` | Ink of the edge shade of pinned columns (one gradient to transparent per side). |
| `--slean-table-pin-shadow-size` | `8px` | Width of the edge shade of pinned columns. |
| `--slean-table-fetching-opacity` | `0.55` | Body opacity while fetching. |
| `--slean-table-skeleton-delay` | `0s` | How long skeleton rows stay invisible, so a fast answer shows none. |
| `--slean-table-refreshing-color` | `--slean-table-primary` | Line drawn while refreshing. |
| `--slean-table-refreshing-height` | `2px` | Thickness of the refreshing line. |
| `--slean-table-sticky-top` | `0px` | Offset of a page-sticky header, for example below a fixed app bar. |
| `--slean-table-move-width` | `36px` | Grip column (onRowMove). |
| `--slean-table-selection-width` | `44px` | Selection column. |
| `--slean-table-expand-width` | `40px` | Expand column (expandColumn). |

### Restyle with tokens

Restyle.svelte

```svelte
<script lang="ts">
	import { DataTable } from '@svelte-lean/table';
	import { memberColumns, pick } from '$lib/demo/columns';
	import { people } from '$lib/demo/data';

	const data = people(6);
	const columns = pick(memberColumns(), ['name', 'status', 'team', 'revenue']);
</script>

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	striped
	pagination={false}
	class="ledger"
	label="Members: restyled with tokens"
/>

<style>
	:global(.slean-table.ledger) {
		--slean-table-radius: 6px;
		--slean-table-header-bg: transparent;
		--slean-table-header-fg: var(--site-text-muted);
		--slean-table-header-font-size: 0.6875rem;
		--slean-table-header-font-weight: 600;
		--slean-table-header-text-transform: uppercase;
		--slean-table-header-letter-spacing: 0.06em;
		--slean-table-row-stripe: color-mix(in oklab, var(--site-surface-2) 70%, transparent);
		--slean-table-cell-padding-x: 1rem;
		--slean-table-cell-padding-y: 0.375rem;
	}
</style>
```

One class on the root sets eight tokens: transparent, uppercase, tracked and muted headers, a zebra stripe from the site surface, a wider and flatter cell padding and a smaller radius. No selector reaches into the table markup.

### Row tints

Body cells paint `var(--slean-table-row-bg, var(--slean-table-bg))`, and hover, stripes and selection work by setting `--slean-table-row-bg` on the row. A class from `rowClass` that sets the same variable tints the whole row, pinned and selection cells included. Mix the tint into `--slean-table-bg` with `color-mix()` rather than using a transparent color, so pinned cells stay opaque over the columns scrolling under them. Application CSS is outside the layer and wins over hover and selection; restate them for tinted rows when they should show.

RowTint.svelte

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

	const data = people(6);
	const columns: Column<Person>[] = memberColumns().map((column) =>
		column.id === 'name' ? { ...column, pin: 'left' } : column
	);
	const tone = (row: Person) =>
		row.status === 'Invited' ? 'tone-warning' : row.progress < 40 ? 'tone-danger' : '';
</script>

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	rowClass={tone}
	selection="multiple"
	pagination={false}
	label="Members: row tints"
/>

<style>
	:global(.slean-table tr.tone-warning) {
		--slean-table-row-bg: color-mix(in oklab, var(--slean-warning-main) 16%, var(--slean-table-bg));
	}

	:global(.slean-table tr.tone-danger) {
		--slean-table-row-bg: color-mix(in oklab, var(--slean-error-main) 12%, var(--slean-table-bg));
	}

	/* Keep hover and selection feedback on tinted rows: a stronger tint, then the selection. */
	:global(.slean-table tr.tone-warning:hover) {
		--slean-table-row-bg: color-mix(in oklab, var(--slean-warning-main) 26%, var(--slean-table-bg));
	}

	:global(.slean-table tr.tone-danger:hover) {
		--slean-table-row-bg: color-mix(in oklab, var(--slean-error-main) 20%, var(--slean-table-bg));
	}

	:global(.slean-table tr:is(.tone-warning, .tone-danger)[data-selected='true']) {
		--slean-table-row-bg: var(--slean-table-row-selected);
	}
</style>
```

Invited members get a warning tint and members under 40% progress a danger tint, each a color-mix() of a status token into the table background. Member is pinned: scroll sideways and the tint stays opaque behind it. Hovered tinted rows deepen the tint; selected rows show the selection.

## API

| Name | Of | Type | Default | Description |
| --- | --- | --- | --- | --- |
| [`density`](https://svelte-lean.vercel.app/table/api.md#prop-density) | Presentation | `'compact' \| 'comfortable' \| 'spacious'` | `'comfortable'` | Cell padding and font size (compact: 13px), as data-density on the root. |
| [`striped`](https://svelte-lean.vercel.app/table/api.md#prop-striped) | Presentation | `boolean` | `false` | Alternate row backgrounds. |
| [`bordered`](https://svelte-lean.vercel.app/table/api.md#prop-bordered) | Presentation | `boolean` | `false` | Vertical cell borders. |
| [`rowHoverable`](https://svelte-lean.vercel.app/table/api.md#prop-rowhoverable) | Presentation | `boolean` | `true` | Row hover background. |
| [`stickyHeader`](https://svelte-lean.vercel.app/table/api.md#prop-stickyheader) | Presentation | `boolean` | `true` | Keeps the \<thead\> in view. With height it sticks inside the scroll region; without, it sticks to the page while the table fits its width (offset by --slean-table-sticky-top). A table wider than its container scrolls sideways in the region, which captures sticky positioning: the header then scrolls with the rows unless the region also scrolls down. |
| [`height`](https://svelte-lean.vercel.app/table/api.md#prop-height) | Presentation | `string` | `undefined` | max-height of the scroll region; with it the region is a Tab stop. |
| [`tableLayout`](https://svelte-lean.vercel.app/table/api.md#prop-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. |
| [`layout`](https://svelte-lean.vercel.app/table/api.md#prop-layout) | Presentation | `'table' \| 'grid'` | `'table'` | table: native table layout. grid: the same elements laid out as CSS grid rows with explicit ARIA roles; body rows stand in sections the browser skips off screen (content-visibility: auto), and column widths are measured from the content once and kept. width is a minimum, maxWidth caps a measured width (default 480), a wrap column narrows below its content before the rows scroll, span and tableLayout do not apply. For pages of hundreds of rows. |
| [`progressive`](https://svelte-lean.vercel.app/table/api.md#prop-progressive) | Presentation | `boolean \| number` | `false` | Progressive mounting: when at least a first batch of rows the table has not drawn arrives (true = one screenful, or the number given), that batch renders at once and the rest follow in slices sized from the measured cost of a row, one macrotask apart, so a large page never blocks the main thread. A first render in the browser mounts the same way; server output and its hydration are always complete. |
| [`label`](https://svelte-lean.vercel.app/table/api.md#prop-label) | Presentation | `string` | `'Data table'` | Accessible name of the scroll region and the \<table\>. |
| [`caption`](https://svelte-lean.vercel.app/table/api.md#prop-caption) | Presentation | `string` | `undefined` | Visible native \<caption\>. |
| [`showHeader`](https://svelte-lean.vercel.app/table/api.md#prop-showheader) | Presentation | `boolean` | `true` | Renders the \<thead\>. |
| [`class`](https://svelte-lean.vercel.app/table/api.md#prop-class) | Presentation | `string` | `''` | Extra class on the root element. |
| [`onScroll`](https://svelte-lean.vercel.app/table/api.md#prop-onscroll) | Events and composition | `(event: Event) => void` | `undefined` | Scroll of the table region. |
| [`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. |
| [`scrollTo`](https://svelte-lean.vercel.app/table/api.md#method-scrollto) | Methods | `(config: { key?: RowId; index?: number; top?: number; offset?: number; align?: ScrollLogicalPosition }) => void` | – | Scrolls the region to a row by id or index, or to a pixel offset. |

## Related

- [Columns](https://svelte-lean.vercel.app/table/columns.md)
- [Localization & RTL](https://svelte-lean.vercel.app/table/localization-rtl.md)
- [Styling](https://svelte-lean.vercel.app/styling.md)
