Skip to content

Table

import { Table } from '@delightstack/components';
import type { Column } from '@delightstack/components';
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 },
]}
/>

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

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' },
]}
/>

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}
/>
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 />
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}
/>

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

virtual_scroll accepts true (defaults), false (off), or an options object:

OptionTypeDefaultDescription
row_heightnumberautoFixed row height in px (auto-measured when omitted)
overscannumber8Extra rows rendered above/below the viewport
scroller'container' | 'parent' | 'window' | string | HTMLElement'container'Which element scrolls
max_heightstring | number420pxViewport height — only for the 'container' scroller

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.

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 },
]}
/>

pagination accepts true (defaults), false (off), or a config object:

OptionTypeDefaultDescription
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_infobooleantrueShow the “Showing X–Y of Z” summary
page_size_optionsnumber[]-Providing it adds a rows-per-page selector with these options
total_itemsnumber-Total row count for server-side paging (see below)
sibling_countnumber1Sibling pages shown either side of the current page
boundary_countnumber1Pages always shown at the start and end
size'0' | '1' | '2' | '3''1'Pager button size

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

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)}
/>

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)}
/>

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 editor Snippet.
  • 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 the Button component.
  • oninput — fires on every keystroke (not a commit).
  • options / onautocomplete — show an autocomplete popover (like Input). options is 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),
},
]}
/>
Name
Email
Status
View code
<Table
skeleton={loading}
skeleton_count={4}
{columns}
data={loading ? [] : rows}
/>
PropTypeDefaultDescription
dataT[][]Array of row data
columnsColumn<T>[][]Column definitions
row_keystring | ((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_bystring-Current sort column key (bindable)
sort_direction'asc' | 'desc''asc'Sort direction (bindable)
selectablebooleanfalseEnable row selection with checkboxes
selectedT[][]Selected rows (bindable)
stripedbooleanfalseAlternating row background colors
densebooleanfalseCompact cell padding
comfortablebooleanfalseRelaxed cell padding
sticky_headerbooleantrueSticky header on vertical scroll
resizablebooleanfalseAllow column width resizing by dragging any column border, in the header or body cells (see Resizable Columns)
expandablebooleanfalseEnable row expansion
reorderablebooleanfalseEnable drag-to-reorder rows (see Reorderable Rows)
virtual_scrollboolean | VirtualScrollOptionsfalseWindow rows for large datasets — true for defaults, or an options object (see Virtual Scrolling)
paginationboolean | PaginationConfigfalsePage the data — true for defaults (10/page), or a config object (see Pagination)
pagenumber1Current page, 1-based (bindable)
page_sizenumber10Rows per page (bindable)
group_bystring-Column key to group rows by
editablebooleanfalseEnable inline cell editing (see Editable Cells)
exportablebooleanfalseEnable CSV/JSON export functionality
skeletonbooleanfalseShow loading skeleton rows
skeleton_countnumber5Number of skeleton rows
idstringautoElement ID
classstring''Additional CSS classes
emptySnippet-Custom empty state content
expanded_rowSnippet<[T]>-Content for expanded rows
EventDetailDescription
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
oncelleditCellEditContext<T>Table-wide commit handler — fires when a cell with no own column.onedit is edited. May return a Promise (→ in-cell spinner)
oncellinputCellEditContext<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')
}
  • Uses semantic <table>, <thead>, <tbody>, <th>, and <td> elements
  • aria-sort on sortable column headers indicates current sort state
  • Checkbox selection uses proper <input type="checkbox"> with aria-label
  • Row expansion uses aria-expanded on toggle buttons
  • Keyboard navigation: Tab through interactive elements, Enter to sort columns
  • Header checkbox has aria-label="Select all rows"
  • Respects prefers-reduced-motion for skeleton shimmer and chevron animations