Build a table from scratch
This is the whole point of headless: the engine computes the rows, you emit
the markup. Here's a complete, sortable, filterable <table> in one Svelte
component - no <SvGrid>, no grid CSS.
1. Wire the engine
When you bypass <SvGrid> you opt into the row models you want. <SvGrid> does
this for you; here you do it explicitly.
<script lang="ts">
import {
createSvGrid,
createCoreRowModel,
createSortedRowModel,
createFilteredRowModel,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type ColumnDef,
} from '@svgrid/grid/core'
type Repo = { name: string; lang: string; stars: number }
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
const columns: ColumnDef<typeof features, Repo>[] = [
{ field: 'name', header: 'Name' },
{ field: 'lang', header: 'Lang' },
{ field: 'stars', header: 'Stars' },
]
const data: Repo[] = [
{ name: 'svelte', lang: 'TypeScript', stars: 79000 },
{ name: 'kit', lang: 'TypeScript', stars: 18000 },
{ name: 'vite', lang: 'TypeScript', stars: 67000 },
{ name: 'esbuild', lang: 'Go', stars: 38000 },
]
// Controlled state - Svelte 5 $state lifts the engine into reactivity.
type Sort = { id: string; desc: boolean }
type Filter = { id: string; value: unknown }
let sorting = $state<Sort[]>([])
let columnFilters = $state<Filter[]>([])
let query = $state('')
$effect(() => {
columnFilters = query ? [{ id: 'name', value: query }] : []
})
// Rebuild when data / state changes. The engine is cheap to recreate.
const table = $derived.by(() =>
createSvGrid({
_features: features,
_rowModels: {
coreRowModel: createCoreRowModel<Repo>(),
filteredRowModel: createFilteredRowModel<Repo>(),
sortedRowModel: createSortedRowModel<Repo>(),
},
data,
columns,
state: { sorting, columnFilters },
// `u` is an Updater: either the next value or a function producing it.
onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),
onColumnFiltersChange: (u) => (columnFilters = typeof u === 'function' ? u(columnFilters) : u),
}),
)
const headerGroups = $derived(table.getHeaderGroups())
// Touch the state this component owns so the derived re-runs:
// the engine's store is framework-free and not a rune.
const rows = $derived.by(() => {
sorting
columnFilters
query
return table.getRowModel().rows
})
function toggleSort(id: string) {
const cur = sorting[0]
sorting =
cur?.id !== id ? [{ id, desc: false }]
: cur.desc ? []
: [{ id, desc: true }]
}
const indicator = (id: string) =>
sorting[0]?.id === id ? (sorting[0]!.desc ? ' ▼' : ' ▲') : ''
</script>
2. Render it - any markup you like
<input placeholder="Filter by name…" bind:value={query} />
<table>
<thead>
{#each headerGroups as hg (hg.id)}
<tr>
{#each hg.headers as h (h.id)}
<th onclick={() => toggleSort(h.column.id)} style="cursor:pointer">
{h.column.columnDef.header}{indicator(h.column.id)}
</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each rows as r (r.id)}
<tr>
{#each columns as col (col.field)}
<td>{(r.original as Repo)[col.field as keyof Repo] ?? ''}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
That's a fully working sortable + filterable grid - the engine did the sorting
and filtering, your 20 lines of template did the rendering. Style the <table>
however you want; the engine has no opinion.
What just happened
_rowModelsdeclared the pipeline: core → filtered → sorted. Skipping grouping and pagination means that code never ships.statein,onXxxChangeout: clicking a header callstoggleSort, which reassignssorting; the$derivedtable re-reads it andgetRowModel()re-sorts.getHeaderGroups()produced the header tree (a single level here; nestedcolumns: [...]would produce more rows).
The smallest thing that works
Two imports, one row model, one {#each}. Nothing here is a grid component -
<table>, <tr> and <td> are yours, and so is every class on them.
<script lang="ts">
import { createSvGrid, createCoreRowModel, tableFeatures, type ColumnDef } from '@svgrid/grid/core'
type Repo = { name: string; lang: string; stars: number }
const data: Repo[] = [
{ name: 'svelte', lang: 'JavaScript', stars: 78000 },
{ name: 'vite', lang: 'TypeScript', stars: 68000 },
{ name: 'sv-grid', lang: 'TypeScript', stars: 172 },
{ name: 'rollup', lang: 'JavaScript', stars: 25000 },
{ name: 'esbuild', lang: 'Go', stars: 38000 },
{ name: 'tinygo', lang: 'Go', stars: 15000 },
{ name: 'bun', lang: 'Zig', stars: 74000 },
{ name: 'zig', lang: 'Zig', stars: 35000 },
]
const features = tableFeatures({})
const columns: ColumnDef<typeof features, Repo>[] = [
{ field: 'name', header: 'Repo' },
{ field: 'lang', header: 'Language' },
{ field: 'stars', header: 'Stars' },
]
const table = createSvGrid({
_features: features,
_rowModels: { coreRowModel: createCoreRowModel<Repo>() },
data,
columns,
})
const rows = $derived(table.getRowModel().rows)
</script>
<table>
<thead>
{#each table.getHeaderGroups() as hg (hg.id)}
<tr>
{#each hg.headers as h (h.id)}
<th>{h.column.columnDef.header}</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each rows as r (r.id)}
{@const repo = r.original as Repo}
<tr>
<td>{repo.name}</td>
<td>{repo.lang}</td>
<td>{repo.stars.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
Now make the headers sort
Add the feature, add the row model, and hang
h.column.getToggleSortingHandler() on the header. The indicator is markup you
write, because the engine has no opinion about what a sorted header looks like.
<script lang="ts">
import {
createSvGrid,
createCoreRowModel,
createSortedRowModel,
tableFeatures,
rowSortingFeature,
type ColumnDef,
} from '@svgrid/grid/core'
type Repo = { name: string; lang: string; stars: number }
const data: Repo[] = [
{ name: 'svelte', lang: 'JavaScript', stars: 78000 },
{ name: 'vite', lang: 'TypeScript', stars: 68000 },
{ name: 'sv-grid', lang: 'TypeScript', stars: 172 },
{ name: 'rollup', lang: 'JavaScript', stars: 25000 },
{ name: 'esbuild', lang: 'Go', stars: 38000 },
{ name: 'tinygo', lang: 'Go', stars: 15000 },
{ name: 'bun', lang: 'Zig', stars: 74000 },
{ name: 'zig', lang: 'Zig', stars: 35000 },
]
const features = tableFeatures({ rowSortingFeature })
const columns: ColumnDef<typeof features, Repo>[] = [
{ field: 'name', header: 'Repo' },
{ field: 'lang', header: 'Language' },
{ field: 'stars', header: 'Stars' },
]
let sorting = $state([{ id: 'stars', desc: true }])
const table = createSvGrid({
_features: features,
_rowModels: {
coreRowModel: createCoreRowModel<Repo>(),
sortedRowModel: createSortedRowModel<Repo>(),
},
data,
columns,
state: { sorting },
onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),
})
// Touch the state this component owns so the derived re-runs:
// the engine's store is framework-free and not a rune.
const rows = $derived.by(() => {
sorting
return table.getRowModel().rows
})
const arrow = (id: string) =>
sorting[0]?.id === id ? (sorting[0].desc ? ' v' : ' ^') : ''
</script>
<table>
<thead>
{#each table.getHeaderGroups() as hg (hg.id)}
<tr>
{#each hg.headers as h (h.id)}
<th style="cursor: pointer;" onclick={h.column.getToggleSortingHandler()}>
{h.column.columnDef.header}{arrow(h.column.id)}
</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each rows as r (r.id)}
{@const repo = r.original as Repo}
<tr>
<td>{repo.name}</td>
<td>{repo.lang}</td>
<td>{repo.stars.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
See also
- Row models - add pagination, grouping, expansion
- Controlled state - lift state out of the component
- Styling a headless table - make it look good, your way
- Headless virtualization - do this for 100k rows
- Cell components - render snippet/component cells in your own table
Related articles
- Building a SaaS Billing and Usage Table in Svelte - How to build a production billing grid with locale-aware currency formatting, grouped subtotals by cost category, and CSV export that matches what is on screen.
- Building a Real-Time Trading Grid in Svelte - How to wire a WebSocket tick feed into SvGrid without dropping frames - one array replacement per animation frame, stable row identity, up/down flashes, and which of the grid's two update paths to use for what.
- Building a Project / Task Board with a Svelte Data Grid - How to build a task management grid with grouping by status or assignee, inline edits, subtask tree rows, and saved views - without reaching for a dedicated project management tool.