Data Table
Batteries-included table for admin list pages — search, filters, sorting, column visibility, pagination, loading skeletons and an empty state in one component. State lives outside the markup — including saved views: call createDataTable({ rows, columns, rowKey }) (rows/columns must be getter functions so the table tracks them) and hand the returned controller in as table. Any column can be drawn by hand with a snippet named after its id — { id: "status" } is rendered by {#snippet status(row)}.
A full table
Search, filters, sorting, column visibility and paging, from one controller and a list of columns. Any list with those controls uses DataTable — hand-rolling from the raw Table primitives is how the same toolbar, empty row, skeleton and pagination end up copied across three screens. createDataTable is the only thing that talks to TanStack; app code never imports it.
| Channel | Status | |||||
|---|---|---|---|---|---|---|
| 3412 | Ada Lovelace ada@example.com | Web | paid | 3 | $248.90 | 2026-08-09 |
| 3411 | Grace Hopper grace@example.com | shipped | 1 | $89.00 | 2026-08-09 | |
| 3410 | Alan Turing alan@example.com | Web | refunded | 2 | $132.50 | 2026-08-08 |
| 3409 | Katherine Johnson katherine@example.com | paid | 5 | $512.20 | 2026-08-08 | |
| 3408 | Margaret Hamilton margaret@example.com | Web | pending | 1 | $45.00 | 2026-08-07 |
<DataTable
{table}
searchPlaceholder="Search orders…"
empty="No orders found."
pageSizeOptions={[5, 10, 25]}
columnToggle
>
{#snippet status(order)}
<Badge variant="secondary" class="capitalize">{order.status}</Badge>
{/snippet}
{#snippet customer(order)}
<div class="min-w-0">
<p class="truncate text-sm font-medium">{order.customer}</p>
<p class="text-muted truncate text-xs">{order.email}</p>
</div>
{/snippet}
</DataTable>Loading
loading renders skeletonRows placeholder rows, but only while the table has no rows yet — a table that already has data keeps it during a refetch instead of blinking back to bars. A column whose cell has a distinctive shape supplies its own <id>Skeleton snippet, so the placeholder row is the height of the row it is about to become.
| Channel | Status | |||||
|---|---|---|---|---|---|---|
<DataTable table={loading} loading skeletonRows={3} searchPlaceholder="Search orders…">
{#snippet customerSkeleton()}
<div class="min-w-0 flex-1 space-y-1.5">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-3 w-40" />
</div>
{/snippet}
{#snippet statusSkeleton()}
<Skeleton class="h-5 w-16 rounded-full" />
{/snippet}
</DataTable>Refreshing
loading on a table that already has rows is a different state from the one above: the rows stay put, and an indeterminate bar plus a dimmed, inert body carry the signal instead — a filter or sort change that refetches the same shape of data reads as *updating*, not as a table that forgot everything it knew a moment ago. Press Refresh to see it.
| Channel | Status | |||||
|---|---|---|---|---|---|---|
| 3412 | Ada Lovelace | Web | paid | 3 | $248.90 | 2026-08-09 |
| 3411 | Grace Hopper | shipped | 1 | $89.00 | 2026-08-09 | |
| 3410 | Alan Turing | Web | refunded | 2 | $132.50 | 2026-08-08 |
| 3409 | Katherine Johnson | paid | 5 | $512.20 | 2026-08-08 | |
| 3408 | Margaret Hamilton | Web | pending | 1 | $45.00 | 2026-08-07 |
<DataTable
table={refreshingTable}
loading={refreshing}
searchPlaceholder="Search orders…"
toolbarEnd={refreshToolbar}
>
{#snippet status(order)}
<Badge variant="secondary" class="capitalize">{order.status}</Badge>
{/snippet}
</DataTable>Custom cells
A cell is either a snippet named after the column's id (customer and status below), or — for a column list assembled from config rather than typed out by hand — cell set directly on the column, as fulfillment does here. A named snippet still wins when both are present, so one column of an otherwise-shared list can be overridden at the call site. Avatar, Badge, Progress and a DropdownMenu cover the shapes that come up again and again: an identity, a state, a quantity, a row's own actions.
| Status | Fulfillment | |||
|---|---|---|---|---|
| 3412 | AL Ada Lovelace ada@example.com | paid | 100% | |
| 3411 | GH Grace Hopper grace@example.com | shipped | 100% | |
| 3410 | AT Alan Turing alan@example.com | refunded | 0% | |
| 3409 | KJ Katherine Johnson katherine@example.com | paid | 100% | |
| 3408 | MH Margaret Hamilton margaret@example.com | pending | 45% |
<DataTable table={cellsTable} pageSizeOptions={[5, 10, 25]}>
{#snippet customer(order)}
<div class="flex min-w-0 items-center gap-2">
<Avatar name={order.customer} />
<div class="min-w-0">
<p class="truncate text-sm font-medium">{order.customer}</p>
<p class="text-muted truncate text-xs">{order.email}</p>
</div>
</div>
{/snippet}
{#snippet status(order)}
<Badge variant={statusVariant[order.status]} class="capitalize"
>{order.status}</Badge
>
{/snippet}
{#snippet actions(order)}
<DropdownMenu
items={[
{ label: 'View order', onSelect: () => {} },
{ label: 'Resend receipt', onSelect: () => {} },
{ separator: true },
{ label: 'Cancel order', destructive: true, onSelect: () => {} }
]}
>
{#snippet trigger()}
<Button
variant="ghost"
size="icon"
aria-label={`Actions for order ${order.id}`}
>
<MoreHorizontal class="size-4" aria-hidden="true" />
</Button>
{/snippet}
</DropdownMenu>
{/snippet}
</DataTable>Views
views + activeView + viewsStorage go to createDataTable, not to DataTable — the controller owns them, so saving, renaming and deleting are one code path the app never re-implements. A fixed view (fixed: true, like both below) is always there and can't be renamed or deleted; a custom one saved through viewsStorage.save gets a rename/delete menu of its own. A view stays pressed only while the query still matches it, and Reset goes back to the active view's own query rather than clearing everything.
| Channel | Status | |||||
|---|---|---|---|---|---|---|
| 3412 | Ada Lovelace | Web | paid | 3 | $248.90 | 2026-08-09 |
| 3411 | Grace Hopper | shipped | 1 | $89.00 | 2026-08-09 | |
| 3410 | Alan Turing | Web | refunded | 2 | $132.50 | 2026-08-08 |
| 3409 | Katherine Johnson | paid | 5 | $512.20 | 2026-08-08 | |
| 3408 | Margaret Hamilton | Web | pending | 1 | $45.00 | 2026-08-07 |
<DataTable table={viewsTable} searchPlaceholder="Search orders…">
{#snippet status(order)}
<Badge variant="secondary" class="capitalize">{order.status}</Badge>
{/snippet}
</DataTable>Empty
Say what is missing, in the table's own body rather than in place of the whole table — the toolbar has to stay reachable, or a reader who filtered themselves into nothing has no way back out.
| Channel | Status | |||||
|---|---|---|---|---|---|---|
| No orders yet. | ||||||
<DataTable table={empty} searchPlaceholder="Search orders…" empty="No orders yet." />Failed to load
When the fetch itself fails, rather than returning zero rows, swap the table for ErrorState entirely — a toolbar and header row over a failure invites filtering or sorting something that was never loaded. Give the reader the one useful action: retry.
Couldn't load orders
Something went wrong while fetching orders. Check your connection and try again.
<div class="bg-card surface-sheen w-full rounded-lg">
<ErrorState
title="Couldn't load orders"
description="Something went wrong while fetching orders. Check your connection and try again."
level={2}
>
{#snippet illustration()}
<CircleAlert class="text-muted mx-auto size-24" aria-hidden="true" />
{/snippet}
<Button variant="secondary" onclick={() => {}}>Try again</Button>
</ErrorState>
</div>API
| Prop | Type | Default | Description |
|---|---|---|---|
table required | DataTableController | Controller returned by | |
rowHref | (row: *) => string | Turns each row into a navigable link to a detail page. | |
onRowClick | (row: *) => void | Click handler for a row, for drawer or modal flows. | |
rowClass | (row: *) => string | Per-row Tailwind classes, e.g. dimming a disabled merchant. | |
loading | boolean | false | Two regimes, chosen by whether the table already has rows. With none yet, renders |
empty | string | 'No results found.' | Message shown in a full-width centered cell when there are no rows. |
searchPlaceholder | string | Placeholder for the toolbar search input. Omit to hide the search field entirely. | |
skeletonRows | number | 5 | Number of skeleton rows rendered while loading. |
filters | boolean | true | Renders the built-in Filters button whenever at least one column is filterable. Set to |
filterOptions | Object<string, Array<{value: *, label: string}>> | {} |
|
filtersLoading | boolean | false | Replaces the Filters popover body with a spinner while option lists are being fetched. |
onFiltersOpen | () => void | Fires every time the Filters popover opens; the hook for lazy-loading | |
columnToggle | boolean | false | Shows the Columns popover listing every column not marked |
resizable | boolean | false | Lets the user drag column edges; switches the table to |
pageSizeOptions | number[] | Row-count choices offered in the footer, e.g. | |
toolbar | import('svelte').Snippet | Rendered in the left toolbar group, after search and Filters. | |
toolbarEnd | import('svelte').Snippet | Rendered in the right toolbar group, before the column toggle. | |
class | string | Additional Tailwind classes merged onto the root wrapper. | |
cells | Object<string, import('svelte').Snippet<[*]>> | Rest props: snippets named after a column's |
DataTableColumn
| Prop | Type | Default | Description |
|---|---|---|---|
id required | string | Unique column id; also the snippet name for custom cell rendering. | |
accessor | string|false | Dot-path into the row for the cell value; defaults to | |
label required | string | Header text. | |
sortable | boolean | Shows a sortable header button. | |
searchable | boolean | Included in the toolbar search; defaults to true when there's an accessor. | |
filter | boolean|'select'|'text'|'number'|'date'|'boolean' | Forces a filter editor type, or | |
options | Array<{value: *, label: string}> | Explicit value list for a | |
align | 'left'|'center'|'right' | Cell/header text alignment. | |
class | string | Extra classes on each | |
headClass | string | Extra classes on the | |
format | (value: *, row: *) => string | Formats the raw value when no custom cell snippet or | |
cell | import('svelte').Snippet<[*]> | Custom cell renderer supplied as data instead of a named snippet — for columns assembled at runtime (e.g. from server-driven config), where there's no fixed | |
hideable | boolean | Set to | |
width | number | Column width in px, used when | |
minWidth | number | Minimum drag width in px. | |
maxWidth | number | Maximum drag width in px. | |
resizable | boolean | Set to | |
hidden | boolean | Initially hidden. |
DataTableQuery
| Prop | Type | Default | Description |
|---|---|---|---|
search required | string | ||
filters required | Object | ||
sort required | {column: string, direction: 'asc'|'desc'}|null | ||
page required | number | ||
perPage required | number |
DataTableState
| Prop | Type | Default | Description |
|---|---|---|---|
search required | string | ||
filters required | Object | ||
sort required | {column: string, direction: 'asc'|'desc'}|null |
DataTableView
| Prop | Type | Default | Description |
|---|---|---|---|
id required | string | ||
name required | string | ||
state required | DataTableState | Applied when this view is selected. | |
fixed | boolean | Always visible; skips the rename/delete menu the custom views get. |
DataTableViewsStorage
| Prop | Type | Default | Description |
|---|---|---|---|
save | (name: string, state: DataTableState) => Promise<DataTableView> | ||
delete | (id: string) => Promise<void> | ||
rename | (id: string, name: string) => Promise<void> |
DataTableController
| Prop | Type | Default | Description |
|---|---|---|---|
rows required | Array<Object> | Visible (paginated/filtered/sorted) rows for the current page. | |
allColumns required | Array<Object> | Every column, including hidden ones (for the Columns toggle). | |
headers required | Array<Object> | Current page's header cells, for the table head row. | |
columns required | Array<Object> | Resolved column defs (for colspan on skeleton/empty rows). | |
filterableColumns required | Array<Object> | Columns eligible for the Filters panel. | |
activeFilters required | Array<{id: string, column: Object, value: *}> | Currently applied filters, for the chips readout. | |
filters required | Object | Draft/applied filter values keyed by column id. | |
dirty required | boolean | True when search/filters/sort differ from what Reset would restore — the active view's own query, or the table's initial one when no view is selected. | |
pending required | boolean | True when | |
search required | string | Bindable search box value. | |
commits required | boolean | True when the table was created with | |
page required | number | Current 1-based page number (bindable). | |
perPage required | number | Rows per page (bindable). | |
total required | number | Total row count across all pages. | |
totalWidth required | number | Sum of column widths, for the table's fixed-layout width when | |
query required | DataTableQuery | Search/filters/sort/page state, for building a server request. | |
state required | DataTableState | Current search/filters/sort, minus paging — what a view saves. | |
applyState required | (state: DataTableState) => void | Restores a saved view's search/filters/sort; resets to page 1. | |
matchesState required | (state: DataTableState) => boolean | Whether the table's current query still matches a given saved state. | |
sortOf required | (columnId: string) => ('asc'|'desc'|false) | Current sort direction for a column, if any. | |
toggleSort required | (columnId: string) => void | Cycles a column's sort direction. | |
resizeColumn required | (columnId: string, width: number) => void | Sets a column's width. | |
toggleColumn required | (columnId: string) => void | Shows/hides a column. | |
clearFilter required | (columnId: string) => void | Removes one active filter. | |
counts required | (columnId: string) => Object | Facet value counts for a column's filter editor. | |
reset required | () => void | Restores what the reader last chose: the active view's own query, or the table's initial search/filters/sort when no view is selected. | |
apply required | () => void | Commits draft search/filters when | |
views required | DataTableView[] | Saved queries, in the order they render. Assign a new array to replace the list (e.g. after fetching it). | |
activeView required | string|undefined | Id of the view Reset restores. Set by | |
canSaveView required | boolean | Whether | |
canRenameView required | boolean | Whether | |
canDeleteView required | boolean | Whether | |
viewPending required | boolean | True while a save/rename/delete is in flight. | |
viewError required | string | Message from the last failed view action; empty when the last one succeeded. | |
isViewActive required | (id: string) => boolean | Whether a view is both the selected one and still what the table is showing — what marks its button | |
selectView required | (id: string) => void | Makes a view active and applies its state. | |
saveView required | (name: string) => Promise<DataTableView|null> | Persists the current state as a new view; resolves | |
renameView required | (id: string, name: string) => Promise<boolean> | Renames a view through storage. | |
deleteView required | (id: string) => Promise<boolean> | Deletes a view through storage, falling back to the first fixed view (and applying its state) when the active one goes. | |
clearViewError required | () => void | Drops the last view error, e.g. when the save dialog reopens. |
Also exported from this module: createDataTable