Table
Import
Section titled “Import”import { Table } from '@delightstack/components';import type { Column } from '@delightstack/components';Basic Usage
Section titled “Basic Usage”| Alice Johnson | alice@example.com | Admin |
| Bob Smith | bob@example.com | Editor |
| Carol White | carol@example.com | Viewer |
| Dave Brown | dave@example.com | Editor |
| Eve Davis | eve@example.com | Admin |
View code
<script> import { Table } from '@delightstack/components';
const users = [ { name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' }, { name: 'Bob Smith', email: 'bob@example.com', role: 'Editor' }, { name: 'Carol White', email: 'carol@example.com', role: 'Viewer' }, { name: 'Dave Brown', email: 'dave@example.com', role: 'Editor' }, { name: 'Eve Davis', email: 'eve@example.com', role: 'Admin' }, ];</script>
<Table data={users} columns={[ { key: 'name', label: 'Name', sortable: true }, { key: 'email', label: 'Email', sortable: true }, { key: 'role', label: 'Role', sortable: true }, ]}/>Examples
Section titled “Examples”Sortable Columns
Section titled “Sortable Columns”Mark columns as sortable and bind the sort state.
| Alice | 30 |
| Bob | 25 |
| Carol | 35 |
View code
<script> import { Table } from '@delightstack/components';
let sort_by = $state('name'); let sort_direction = $state<'asc' | 'desc'>('asc');
const data = [ { name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }, { name: 'Carol', age: 35 }, ];</script>
<Table {data} columns={[ { key: 'name', label: 'Name', sortable: true }, { key: 'age', label: 'Age', sortable: true, align: 'right' }, ]} bind:sort_by bind:sort_direction/>Custom Cell Rendering
Section titled “Custom Cell Rendering”Use Svelte snippets to render custom cell content.
Status | Role | ||
|---|---|---|---|
| Alice Johnson | active | Admin | |
| Bob Smith | inactive | Editor | |
| Carol White | active | Viewer | |
| Dave Brown | pending | Editor | |
| Eve Davis | active | Admin |
View code
<script> import { Table, Button } from '@delightstack/components';</script>
{#snippet statusCell({ value })} <span class="status-badge">{value}</span>{/snippet}
{#snippet actionsCell({ row })} <Button size="0" onclick={() => editUser(row)}>Edit</Button>{/snippet}
<Table data={users} columns={[ { key: 'name', label: 'Name', sortable: true }, { key: 'status', label: 'Status', cell: statusCell }, { key: 'role', label: 'Role' }, { key: 'actions', label: '', cell: actionsCell, width: '100px' }, ]}/>Row Selection with Checkboxes
Section titled “Row Selection with Checkboxes”Enable selectable to show checkboxes. Supports shift-click range selection.
Email | Role | ||
|---|---|---|---|
| Alice Johnson | alice@example.com | Admin | |
| Bob Smith | bob@example.com | Editor | |
| Carol White | carol@example.com | Viewer | |
| Dave Brown | dave@example.com | Editor | |
| Eve Davis | eve@example.com | Admin |
0 rows selected
View code
<script> import { Table } from '@delightstack/components';
let selectedUsers = $state([]);</script>
<Table data={users} columns={columns} selectable bind:selected={selectedUsers}/>Striped and Dense
Section titled “Striped and Dense”Name | Email | Role |
|---|---|---|
| Alice Johnson | alice@example.com | Admin |
| Bob Smith | bob@example.com | Editor |
| Carol White | carol@example.com | Viewer |
| Dave Brown | dave@example.com | Editor |
| Eve Davis | eve@example.com | Admin |
View code
<Table data={users} columns={columns} striped dense />Expandable Rows
Section titled “Expandable Rows”Name | Email | |
|---|---|---|
| Alice Johnson | alice@example.com | |
| Bob Smith | bob@example.com | |
| Carol White | carol@example.com |
View code
{#snippet rowDetail(row)} <p>Details for {row.name}: {row.bio}</p>{/snippet}
<Table data={users} columns={columns} expandable expanded_row={rowDetail}/>Virtual Scrolling
Section titled “Virtual Scrolling”Set virtual_scroll to render only the rows near the viewport, so the table stays
fast with thousands of rows. Pass true for sensible defaults, or an options
object to configure it. Row heights are auto-measured; sorting, selection,
striping, and row clicks all work as usual. Virtualization applies to the flat
data path — group_by tables render normally.
Email | |||
|---|---|---|---|
| 1 | User 1 | user1@example.com | Admin |
| 2 | User 2 | user2@example.com | Editor |
| 3 | User 3 | user3@example.com | Viewer |
| 4 | User 4 | user4@example.com | Owner |
| 5 | User 5 | user5@example.com | Guest |
| 6 | User 6 | user6@example.com | Admin |
| 7 | User 7 | user7@example.com | Editor |
| 8 | User 8 | user8@example.com | Viewer |
| 9 | User 9 | user9@example.com | Owner |
| 10 | User 10 | user10@example.com | Guest |
| 11 | User 11 | user11@example.com | Admin |
| 12 | User 12 | user12@example.com | Editor |
| 13 | User 13 | user13@example.com | Viewer |
| 14 | User 14 | user14@example.com | Owner |
| 15 | User 15 | user15@example.com | Guest |
| 16 | User 16 | user16@example.com | Admin |
| 17 | User 17 | user17@example.com | Editor |
10,000 rows — scroll the table; only the visible window renders.
View code
<script> import { Table } from '@delightstack/components';
// 10,000 rows — only the visible window is ever in the DOM const data = Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, email: `user${i + 1}@example.com`, role: ['Admin', 'Editor', 'Viewer'][i % 3], }));</script>
<Table {data} columns={[ { key: 'id', label: '#', width: '80px', align: 'right' }, { key: 'name', label: 'Name', sortable: true }, { key: 'email', label: 'Email' }, { key: 'role', label: 'Role', sortable: true }, ]} virtual_scroll={{ max_height: 360 }} striped/>Options
Section titled “Options”virtual_scroll accepts true (defaults), false (off), or an options object:
| Option | Type | Default | Description |
|---|---|---|---|
row_height | number | auto | Fixed row height in px (auto-measured when omitted) |
overscan | number | 8 | Extra rows rendered above/below the viewport |
scroller | 'container' | 'parent' | 'window' | string | HTMLElement | 'container' | Which element scrolls |
max_height | string | number | 420px | Viewport height — only for the 'container' scroller |
Choosing the scroll container
Section titled “Choosing the scroll container”By default the table provides its own scroll frame (scroller: 'container'),
bounded by max_height. To virtualize against an outer scrollbar instead, set
scroller:
<!-- Scroll the whole page --><Table {data} {columns} virtual_scroll={{ scroller: 'window' }} />
<!-- Scroll the Table's parent element (give it a height + overflow) --><div style="height: 70vh; overflow: auto;"> <Table {data} {columns} virtual_scroll={{ scroller: 'parent' }} /></div>
<!-- Scroll a specific element by selector or reference --><Table {data} {columns} virtual_scroll={{ scroller: '#my-scroll-area' }} />For 'parent', 'window', and custom scrollers the table renders inline (no
inner scroll frame) and max_height is ignored — the chosen element owns the
scroll height.
Pagination
Section titled “Pagination”Add the pagination flag and the Table pages your data for you — 10 rows per page
with a numbered pager and a “Showing X–Y of Z” summary beneath the table. Sorting,
selection, and inline editing all work across pages (selection keys off each row’s
position in the full data, not the visible page).
That’s the whole API for the common case:
<Table {data} {columns} pagination />Bind page and page_size to read or drive the current page, and pass a config
object to tune the pager. Providing page_size_options adds a rows-per-page
selector.
Email | ||
|---|---|---|
| User 1 | user1@example.com | Admin |
| User 2 | user2@example.com | Editor |
| User 3 | user3@example.com | Viewer |
| User 4 | user4@example.com | Admin |
| User 5 | user5@example.com | Editor |
| User 6 | user6@example.com | Viewer |
| User 7 | user7@example.com | Admin |
| User 8 | user8@example.com | Editor |
| User 9 | user9@example.com | Viewer |
| User 10 | user10@example.com | Admin |
View code
<script> import { Table } from '@delightstack/components';
const roles = ['Admin', 'Editor', 'Viewer']; const data = Array.from({ length: 42 }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, email: `user${i + 1}@example.com`, role: roles[i % roles.length], }));
let page = $state(1); let page_size = $state(10);</script>
<Table {data} row_key="id" bind:page bind:page_size pagination={{ page_size_options: [5, 10, 25] }} columns={[ { key: 'name', label: 'Name', sortable: true }, { key: 'email', label: 'Email' }, { key: 'role', label: 'Role', sortable: true }, ]}/>Options
Section titled “Options”pagination accepts true (defaults), false (off), or a config object:
| Option | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'simple' | 'compact' | 'default' | Pager style — a numbered pager, Prev · Page X of Y · Next, or ‹ X / Y › |
position | 'top' | 'bottom' | 'both' | 'bottom' | Where the pager sits relative to the table |
align | 'start' | 'center' | 'end' | 'between' | 'between' | 'between' splits the summary left / controls right; the others align the whole pager |
show_info | boolean | true | Show the “Showing X–Y of Z” summary |
page_size_options | number[] | - | Providing it adds a rows-per-page selector with these options |
total_items | number | - | Total row count for server-side paging (see below) |
sibling_count | number | 1 | Sibling pages shown either side of the current page |
boundary_count | number | 1 | Pages always shown at the start and end |
size | '0' | '1' | '2' | '3' | '1' | Pager button size |
Server-side pagination
Section titled “Server-side pagination”By default the Table slices data itself (client-side). For large datasets you
fetch a page at a time: set total_items to the full row count and feed the Table
only the current page — it won’t slice. Bind page/page_size (or use
onpagechange) to fetch when they change.
<script> let page = $state(1); let page_size = $state(25); let total = $state(0); let rows = $state([]);
$effect(() => { fetchPage(page, page_size).then((res) => { rows = res.items; total = res.total; }); });</script>
<Table data={rows} {columns} bind:page bind:page_size pagination={{ total_items: total, page_size_options: [25, 50, 100] }}/>In server mode, sorting via the built-in headers only reorders the current page;
use the onsort callback to sort on the server instead.
While pagination is active, virtual_scroll and reorderable are disabled (the
page slice already bounds how many rows render).
Reorderable Rows
Section titled “Reorderable Rows”Set reorderable to let users drag rows into a new order. On desktop, press and
drag a row; on touch, hold a row until it lifts, then drag. The new order is
handed to onreorder only after the drop animation finishes, so assigning it to
your data never interrupts the animation. Works with selectable (drag the
whole selection at once) and virtual_scroll.
Give each row a stable row_key (a field name or (row) => id) when
combining reorderable with selectable. Without it, rows are keyed by their
position, so committing a new order makes Svelte re-key every row by index — and
the selection checkmarks redraw (a brief flash) even though the same rows stay
selected. With a row_key, Svelte moves each row’s DOM node to follow its data,
so the checkmarks ride along untouched.
# | Name | Role | |
|---|---|---|---|
| 1 | Alice Johnson | Admin | |
| 2 | Bob Smith | Editor | |
| 3 | Carol White | Viewer | |
| 4 | Dave Brown | Editor | |
| 5 | Eve Davis | Admin | |
| 6 | Frank Green | Viewer |
— · order: 1, 2, 3, 4, 5, 6
View code
<script> import { Table } from '@delightstack/components';
let users = $state([ { rank: 1, name: 'Alice Johnson', role: 'Admin' }, { rank: 2, name: 'Bob Smith', role: 'Editor' }, { rank: 3, name: 'Carol White', role: 'Viewer' }, ]);</script>
<Table data={users} columns={[ { key: 'rank', label: '#', width: '56px', align: 'right' }, { key: 'name', label: 'Name' }, { key: 'role', label: 'Role' }, ]} row_key="rank" reorderable selectable onreorder={(e) => (users = e.newData)}/>Resizable Columns
Section titled “Resizable Columns”Set resizable to let users drag any column border to resize it — in the header
or anywhere down the body rows. The visible divider stays a crisp 1px line,
but the drag target is widened with a few px of slack so you grab the border
without having to land on the hairline. Bring the pointer to a border and the
whole column boundary previews as an accent line, head to foot; while you drag,
it lights up fully so it’s clear what’s moving.
It works with mouse, touch, and pen. The header border is also a focusable
separator: arrow keys nudge the width (hold Shift for a larger step) and
Home (or Enter) auto-fits. Double-click a border to reset that column
to its content-driven width, and Escape mid-drag cancels. Each committed
change fires oncolumnresize with the column key and new pixel width.
SKU | Product | Category | Price |
|---|---|---|---|
| DS-1001 | Aurora Standing Desk | Furniture | $649 |
| DS-1002 | Nimbus Office Chair | Furniture | $329 |
| DS-1003 | Halo Desk Lamp | Lighting | $89 |
| DS-1004 | Drift Acoustic Panel | Decor | $48 |
| DS-1005 | Pulse USB-C Hub | Electronics | $72 |
drag any column border (header or rows) · double-click to auto-fit
View code
<script> import { Table } from '@delightstack/components';
const products = [ { sku: 'DS-1001', name: 'Aurora Standing Desk', category: 'Furniture', price: '$649' }, { sku: 'DS-1002', name: 'Nimbus Office Chair', category: 'Furniture', price: '$329' }, { sku: 'DS-1003', name: 'Halo Desk Lamp', category: 'Lighting', price: '$89' }, ];</script>
<Table data={products} columns={[ { key: 'sku', label: 'SKU', minWidth: '90px' }, { key: 'name', label: 'Product', minWidth: '140px' }, { key: 'category', label: 'Category', minWidth: '110px' }, { key: 'price', label: 'Price', align: 'right', minWidth: '80px' }, ]} resizable oncolumnresize={(e) => console.log(e.column, e.width)}/>Editable Cells
Section titled “Editable Cells”Set editable to turn the table into a spreadsheet-style editor. Click a cell (or
Tab into the grid) and it opens an inline editor immediately. Hover highlights the
cell rather than the whole row, signalling that each cell is clickable.
Keyboard: ↑ / ↓ move between rows, Tab / Shift+Tab move between cells (wrapping rows), Enter saves and moves down, ← / → move the caret and jump to the neighbouring cell at the text edge, and Esc reverts. Ctrl/⌘+Z and Ctrl/⌘+Y undo and redo edits.
Each column configures its own editor and callbacks:
editor—'text'(default),'number','select','boolean','date', or a custom editorSnippet.onedit— fires when a cell is committed (Enter or blur) and the value changed. If it returns a Promise, the cell shows a loading spinner while it resolves (then a success check); if it rejects, the cell keeps your value and shows an error ring so you can retry — just like theButtoncomponent.oninput— fires on every keystroke (not a commit).options/onautocomplete— show an autocomplete popover (likeInput).optionsis a static list filtered by the current value;onautocomplete(ctx)returns results (or a Promise of them) dynamically, debounced 300ms.validate— return an error message to block the commit and ring the cell.parse/format— convert between the editor string and your stored value.
The table is controlled: editing fires onedit and your handler updates data (an
optimistic value is shown while an async save runs). Editing composes with selectable,
reorderable (a drag grip appears so a cell click still edits), virtual_scroll,
sortable, and group_by. Opt a column out with editable: false.
Name | Age | Role | Active |
|---|---|---|---|
| Alice Johnson | 30 | Admin | |
| Bob Smith | 25 | Editor | |
| Carol White | 35 | Viewer | |
| Dave Brown | 28 | Editor | |
| Eve Davis | 42 | Admin |
Click a cell or Tab in to edit · ↑↓ move rows · Tab moves cells · Enter saves · last: —
View code
<script> import { Table } from '@delightstack/components';
let people = $state([ { id: 1, name: 'Alice Johnson', age: 30, role: 'Admin', active: true }, { id: 2, name: 'Bob Smith', age: 25, role: 'Editor', active: false }, { id: 3, name: 'Carol White', age: 35, role: 'Viewer', active: true }, ]);
const set = (row, key, value) => (row[key] = value);</script>
<Table data={people} row_key="id" editable columns={[ { key: 'name', label: 'Name', minWidth: '180px', options: ['Bob Smith', 'Bob Smithsonian', 'Bob Schmidt'], // async save → in-cell spinner, then a success check onedit: ({ row, value }) => new Promise((r) => setTimeout(() => { set(row, 'name', value); r(); }, 800)), }, { key: 'age', label: 'Age', width: '90px', align: 'right', editor: 'number', validate: (v) => (v >= 0 && v <= 120 ? null : 'Enter 0–120'), onedit: ({ row, value }) => set(row, 'age', value), }, { key: 'role', label: 'Role', editor: 'select', options: ['Admin', 'Editor', 'Viewer'], onedit: ({ row, value }) => set(row, 'role', value), }, { key: 'active', label: 'Active', width: '90px', align: 'center', editor: 'boolean', onedit: ({ row, value }) => set(row, 'active', value), }, ]}/>Skeleton Loading
Section titled “Skeleton Loading”Name | Email | Status |
|---|
View code
<Table skeleton={loading} skeleton_count={4} {columns} data={loading ? [] : rows}/>| Prop | Type | Default | Description |
|---|---|---|---|
data | T[] | [] | Array of row data |
columns | Column<T>[] | [] | Column definitions |
row_key | string | ((row: T) => string | number) | - | Stable per-row identity for the keyed {#each}. A field name or function. Lets Svelte move row DOM nodes (so selection survives a reorder commit without redrawing) instead of re-keying by position. Recommended for reorderable + selectable |
sort_by | string | - | Current sort column key (bindable) |
sort_direction | 'asc' | 'desc' | 'asc' | Sort direction (bindable) |
selectable | boolean | false | Enable row selection with checkboxes |
selected | T[] | [] | Selected rows (bindable) |
striped | boolean | false | Alternating row background colors |
dense | boolean | false | Compact cell padding |
comfortable | boolean | false | Relaxed cell padding |
sticky_header | boolean | true | Sticky header on vertical scroll |
resizable | boolean | false | Allow column width resizing by dragging any column border, in the header or body cells (see Resizable Columns) |
expandable | boolean | false | Enable row expansion |
reorderable | boolean | false | Enable drag-to-reorder rows (see Reorderable Rows) |
virtual_scroll | boolean | VirtualScrollOptions | false | Window rows for large datasets — true for defaults, or an options object (see Virtual Scrolling) |
pagination | boolean | PaginationConfig | false | Page the data — true for defaults (10/page), or a config object (see Pagination) |
page | number | 1 | Current page, 1-based (bindable) |
page_size | number | 10 | Rows per page (bindable) |
group_by | string | - | Column key to group rows by |
editable | boolean | false | Enable inline cell editing (see Editable Cells) |
exportable | boolean | false | Enable CSV/JSON export functionality |
skeleton | boolean | false | Show loading skeleton rows |
skeleton_count | number | 5 | Number of skeleton rows |
id | string | auto | Element ID |
class | string | '' | Additional CSS classes |
empty | Snippet | - | Custom empty state content |
expanded_row | Snippet<[T]> | - | Content for expanded rows |
Events
Section titled “Events”| Event | Detail | Description |
|---|---|---|
onsort | { column: string, direction: 'asc' | 'desc' } | Sort changed (use for server-side sorting) |
onselect | { selected: T[] } | Selection changed |
onrowclick | { row: T, index: number } | Row clicked |
oncolumnresize | { column: string, width: number } | Column resized |
onreorderstart | { from: number[] } | A reorder drag began (rows lifted) |
ondrop | { from: number[], to: number } | Rows released — fires when the drop animation begins |
onreorder | { from: number[], to: number, oldData: T[], newData: T[] } | Reorder committed — fires after the drop animation finishes; assign newData to your data |
oncelledit | CellEditContext<T> | Table-wide commit handler — fires when a cell with no own column.onedit is edited. May return a Promise (→ in-cell spinner) |
oncellinput | CellEditContext<T> | Table-wide per-keystroke handler — fallback when a column has no own column.oninput |
onpagechange | { page: number, page_size: number } | Page or page size changed — use for server-side paging (fires for client-side too) |
Most editing callbacks live on the column (onedit, oninput, onautocomplete,
validate); oncelledit / oncellinput are table-wide fallbacks for a single shared
handler. See Editable Cells.
The Column interface defines column configuration:
interface Column<T> { key: string; label: string; sortable?: boolean; width?: string; minWidth?: string; align?: 'left' | 'center' | 'right'; cell?: Snippet<[{ value: unknown; row: T; index: number }]>; header?: Snippet<[{ column: Column<T> }]>;
// Inline editing (active only when the Table's `editable` prop is on) editable?: boolean | ((row: T) => boolean); // per-column override / per-cell predicate editor?: 'text' | 'number' | 'select' | 'boolean' | 'date' | Snippet<[CellEditorContext<T>]>; // a Snippet is a custom editor options?: CellOption[] | string[]; // static autocomplete / select options onautocomplete?: (ctx: CellAutocompleteContext<T>) => CellOption[] | Promise<CellOption[]>; // dynamic options (debounced 300ms) oninput?: (ctx: CellEditContext<T>) => void; // per keystroke onedit?: (ctx: CellEditContext<T>) => void | Promise<void>; // commit (Promise → spinner) validate?: (value: unknown, row: T, index: number) => string | null | Promise<string | null>; // error message blocks the commit parse?: (raw: string, row: T) => unknown; // editor string → stored value format?: (value: unknown, row: T) => string; // stored value → display string placeholder?: string;}
interface CellOption { value: string; label?: string; description?: string; disabled?: boolean;}
interface CellEditContext<T> { value: unknown; // the committed (parsed) value, or the live value for oninput previous: unknown; // the prior stored value row: T; index: number; column: Column<T>; key: string; // the edited column's key}The virtual_scroll prop is boolean | VirtualScrollOptions:
type VirtualScroller = | 'container' // the Table's own scroll frame (default) | 'parent' // the Table's direct parent element | 'window' // the page / document | string // a CSS selector for a scrollable ancestor | HTMLElement;
interface VirtualScrollOptions { row_height?: number; // fixed row height in px (auto-measured when omitted) overscan?: number; // extra rows above/below the viewport (default 8) scroller?: VirtualScroller; // which element scrolls (default 'container') max_height?: string | number; // viewport height — only for the 'container' scroller}The pagination prop is boolean | PaginationConfig:
interface PaginationConfig { variant?: 'default' | 'simple' | 'compact'; // pager style (default 'default') position?: 'top' | 'bottom' | 'both'; // pager placement (default 'bottom') align?: 'start' | 'center' | 'end' | 'between'; // pager alignment (default 'between') show_info?: boolean; // show the "Showing X–Y of Z" summary (default true) page_size_options?: number[]; // providing it adds a rows-per-page selector total_items?: number; // total count for server-side paging (Table won't slice) sibling_count?: number; // sibling pages around the current page (default 1) boundary_count?: number; // pages always shown at each end (default 1) size?: '0' | '1' | '2' | '3'; // pager button size (default '1')}Accessibility
Section titled “Accessibility”- Uses semantic
<table>,<thead>,<tbody>,<th>, and<td>elements aria-sorton sortable column headers indicates current sort state- Checkbox selection uses proper
<input type="checkbox">witharia-label - Row expansion uses
aria-expandedon toggle buttons - Keyboard navigation: Tab through interactive elements, Enter to sort columns
- Header checkbox has
aria-label="Select all rows" - Respects
prefers-reduced-motionfor skeleton shimmer and chevron animations