Table Structure
Layout
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
- Fixture
- Benchmark
On this page
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.
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 |
<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" />- Row hover is a fourth attribute,
data-hoverable, on by default. - Under
forced-colors: activethe stylesheet uses system colors for selection and ranges.
Sticky header
Keep headers visible inside a constrained scrolling surface.
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 |
<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"
/>stickyHeaderis on by default. Withheightthe 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-topmoves 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, undercontent-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
widthis a minimum andmaxWidth(default 480) caps a measured width; a capped column clips with an ellipsis. Spare width is shared in proportion to the content. tableLayoutand cellspando not apply. Combine withprogressiveso 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.
<DataTable
{data}
{columns}
getRowId={(row) => row.id}
label="Invoices"
caption="Open invoices, updated hourly"
tableLayout="auto"
showHeader={false}
rowHoverable={false}
class="invoices"
/>/* 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.
| 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-accent | --slean-accent | Checkbox fallback, resize handle, ranges. |
--slean-table-selected | --slean-accent-soft | Cell ranges; default of row-selected. |
--slean-table-danger | --slean-danger | Error alert and editor validation text. |
--slean-table-focus | --slean-focus-ring-color | Focus rings of controls, rows and cells. |
--slean-table-shadow | --slean-shadow-lg | 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-pin-shadow | black at 10% (45% dark) | Edge shadow of pinned columns. |
--slean-table-fetching-opacity | 0.55 | Body opacity while fetching. |
--slean-table-sticky-top | 0px | Offset of a page-sticky header, for example below a fixed app bar. |
--slean-table-selection-width | 44px | Selection column. |
--slean-table-expand-width | 40px | Expand column (expandColumn). |
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 |
<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>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.
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 |
<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>API
| Name | Of | Type | Default | Description |
|---|---|---|---|---|
density | Presentation | 'compact' | 'comfortable' | 'spacious' | 'comfortable' | Cell padding and font size (compact: 13px), as data-density on the root. |
striped | Presentation | boolean | false | Alternate row backgrounds. |
bordered | Presentation | boolean | false | Vertical cell borders. |
rowHoverable | Presentation | boolean | true | Row hover background. |
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 | Presentation | string | undefined | max-height of the scroll region; with it the region is a Tab stop. |
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 | 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), span and tableLayout do not apply. For pages of hundreds of rows. |
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 | Presentation | string | 'Data table' | Accessible name of the scroll region and the <table>. |
caption | Presentation | string | undefined | Visible native <caption>. |
showHeader | Presentation | boolean | true | Renders the <thead>. |
class | Presentation | string | '' | Extra class on the root element. |
onScroll | Events and composition | (event: Event) => void | undefined | Scroll of the table region. |
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 | 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. |