sveltelean Table
Versionv0.2.0 GitHub

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.

Density and borders
Density
Amara Okafor
Active
Engineering
Europe
$4,501
20%
2026-01-07
Jonas Lindqvist
Active
Design
Americas
$12,420
33%
2026-06-18
Mei Tanaka
Away
Operations
Asia
$20,339
46%
2026-02-01
Rafael Duarte
Invited
Growth
Europe
$28,258
59%
2026-07-12
Selin Aydın
Active
Engineering
Americas
$36,177
72%
2026-03-23
Noah Brenner
Active
Design
Asia
$44,096
85%
2026-08-06

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

Keep headers visible inside a constrained scrolling surface.

Sticky header
All rows in one scrolling region
Amara Okafor
Active
Engineering
Europe
$4,501
20%
2026-01-07
Jonas Lindqvist
Active
Design
Americas
$12,420
33%
2026-06-18
Mei Tanaka
Away
Operations
Asia
$20,339
46%
2026-02-01
Rafael Duarte
Invited
Growth
Europe
$28,258
59%
2026-07-12
Selin Aydın
Active
Engineering
Americas
$36,177
72%
2026-03-23
Noah Brenner
Active
Design
Asia
$44,096
85%
2026-08-06
Priya Raman
Away
Operations
Europe
$4,015
98%
2026-04-17
Elias Moreau
Invited
Growth
Americas
$11,934
30%
2026-09-28
Hana Kowalska
Active
Engineering
Asia
$19,853
43%
2026-05-11
Ömer Say
Active
Design
Europe
$27,772
56%
2026-01-22
İpek Yılmaz
Away
Operations
Americas
$35,691
69%
2026-06-05
Lucas Ferreira
Invited
Growth
Asia
$43,610
82%
2026-02-16
Amara Moreau
Active
Engineering
Europe
$3,529
95%
2026-07-27
Jonas Kowalska
Active
Design
Americas
$11,448
27%
2026-03-10
Mei Say
Away
Operations
Asia
$19,367
40%
2026-08-21
Rafael Yılmaz
Invited
Growth
Europe
$27,286
53%
2026-04-04
Selin Ferreira
Active
Engineering
Americas
$35,205
66%
2026-09-15
Noah Okafor
Active
Design
Asia
$43,124
79%
2026-05-26
Priya Lindqvist
Away
Operations
Europe
$3,043
92%
2026-01-09
Elias Tanaka
Invited
Growth
Americas
$10,962
24%
2026-06-20
Hana Duarte
Active
Engineering
Asia
$18,881
37%
2026-02-03
Ömer Aydın
Active
Design
Europe
$26,800
50%
2026-07-14
İpek Brenner
Away
Operations
Americas
$34,719
63%
2026-03-25
Lucas Raman
Invited
Growth
Asia
$42,638
76%
2026-08-08
Amara Tanaka
Active
Engineering
Europe
$2,557
89%
2026-04-19
Jonas Duarte
Active
Design
Americas
$10,476
21%
2026-09-02
Mei Aydın
Away
Operations
Asia
$18,395
34%
2026-05-13
Rafael Brenner
Invited
Growth
Europe
$26,314
47%
2026-01-24
Selin Raman
Active
Engineering
Americas
$34,233
60%
2026-06-07
Noah Moreau
Active
Design
Asia
$42,152
73%
2026-02-18
Priya Kowalska
Away
Operations
Europe
$2,071
86%
2026-07-01
Elias Say
Invited
Growth
Americas
$9,990
99%
2026-03-12
Hana Yılmaz
Active
Engineering
Asia
$17,909
31%
2026-08-23
Ömer Ferreira
Active
Design
Europe
$25,828
44%
2026-04-06
İpek Okafor
Away
Operations
Americas
$33,747
57%
2026-09-17
Lucas Lindqvist
Invited
Growth
Asia
$41,666
70%
2026-05-28

StickyHeader.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 50, 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, so a fast scroll mostly pays for paint.
  • Column widths are measured once from the content, on hidden copies of the header and a sample of rows, never on the rows on screen; they only grow while the columns stay the same. 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
<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	label="Invoices"
	caption="Open invoices, updated hourly"
	tableLayout="auto"
	showHeader={false}
	rowHoverable={false}
	class="invoices"
/>
app.css
/* Retint one table; every property has a neutral fallback in style.css. */
.slean-table.invoices {
	--slean-table-accent: 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.

TokenDefaultPaints
--slean-table-bg--slean-surfaceRoot and cell background.
--slean-table-fg--slean-fgText.
--slean-table-muted--slean-fg-mutedSecondary text: status line, placeholder.
--slean-table-border--slean-borderOutline, row lines, control borders.
--slean-table-soft--slean-mutedHover of controls, skeleton bars, group rows.
--slean-table-accent--slean-accentCheckbox fallback, resize handle, ranges.
--slean-table-selected--slean-accent-softCell ranges; default of row-selected.
--slean-table-danger--slean-dangerError alert and editor validation text.
--slean-table-focus--slean-focus-ring-colorFocus rings of controls, rows and cells.
--slean-table-shadow--slean-shadow-lgColumn controls panel.
--slean-table-radius--slean-radius-lgRoot corners.
--slean-table-control-radius--slean-radius-smButtons, inputs, selects.
--slean-table-control-height--slean-control-height-sm (2rem)Toolbar and pager controls.
--slean-table-font--slean-font-sansFont family.
--slean-table-font-size--slean-text-sm (0.875rem)Body text; 0.8125rem when compact.
--slean-table-duration--slean-duration-fastTransitions (reduced motion: none).
--slean-table-cell-padding-x0.75remHorizontal cell padding.
--slean-table-cell-padding-y0.5remVertical cell padding; set by density.
--slean-table-header-bgsoft mixed into bgHeader, filter row, footer.
--slean-table-header-fgmutedHeader text.
--slean-table-header-font-size0.8125remHeader text size.
--slean-table-header-font-weight500Header weight.
--slean-table-header-text-transformnoneFor example uppercase.
--slean-table-header-letter-spacingnormalTracking of uppercase headers.
--slean-table-row-hoversoft mixed into bgHovered row.
--slean-table-row-stripesoft mixed into bgEven rows when striped; detail rows.
--slean-table-row-selectedselectedSelected row.
--slean-table-row-bgbgPer row: what every cell of the row paints.
--slean-table-pin-shadowblack at 10% (45% dark)Edge shadow of pinned columns.
--slean-table-fetching-opacity0.55Body opacity while fetching.
--slean-table-sticky-top0pxOffset of a page-sticky header, for example below a fixed app bar.
--slean-table-selection-width44pxSelection column.
--slean-table-expand-width40pxExpand column (expandColumn).

Restyle with tokens

Restyle with tokens
Amara Okafor
Active
Engineering
$4,501
Jonas Lindqvist
Active
Design
$12,420
Mei Tanaka
Away
Operations
$20,339
Rafael Duarte
Invited
Growth
$28,258
Selin Aydın
Active
Engineering
$36,177
Noah Brenner
Active
Design
$44,096

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

Row tints
0 selected
Amara Okafor
Active
Engineering
Europe
$4,501
20%
2026-01-07
Jonas Lindqvist
Active
Design
Americas
$12,420
33%
2026-06-18
Mei Tanaka
Away
Operations
Asia
$20,339
46%
2026-02-01
Rafael Duarte
Invited
Growth
Europe
$28,258
59%
2026-07-12
Selin Aydın
Active
Engineering
Americas
$36,177
72%
2026-03-23
Noah Brenner
Active
Design
Asia
$44,096
85%
2026-08-06

RowTint.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) 16%, var(--slean-table-bg));
	}

	:global(.slean-table tr.tone-danger) {
		--slean-table-row-bg: color-mix(in oklab, var(--slean-danger) 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) 26%, var(--slean-table-bg));
	}

	:global(.slean-table tr.tone-danger:hover) {
		--slean-table-row-bg: color-mix(in oklab, var(--slean-danger) 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

NameOfTypeDefaultDescription
densityPresentation'compact' | 'comfortable' | 'spacious''comfortable'Cell padding and font size (compact: 13px), as data-density on the root.
stripedPresentationbooleanfalseAlternate row backgrounds.
borderedPresentationbooleanfalseVertical cell borders.
rowHoverablePresentationbooleantrueRow hover background.
stickyHeaderPresentationbooleantrueKeeps 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.
heightPresentationstringundefinedmax-height of the scroll region; with it the region is a Tab stop.
tableLayoutPresentation'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.
layoutPresentation'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), span and tableLayout do not apply. For pages of hundreds of rows.
progressivePresentationboolean | numberfalseProgressive 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.
labelPresentationstring'Data table'Accessible name of the scroll region and the <table>.
captionPresentationstringundefinedVisible native <caption>.
showHeaderPresentationbooleantrueRenders the <thead>.
classPresentationstring''Extra class on the root element.
onScrollEvents and composition(event: Event) => voidundefinedScroll of the table region.
rowClassEvents and composition(row: T, index: number) => stringundefinedClass 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.
scrollToMethods(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.