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'
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 },
onSortingChange: (u) =>
(sorting = typeof u === 'function' ? (u as (s: Sort[]) => Sort[])(sorting) : u),
onColumnFiltersChange: (u) =>
(columnFilters = typeof u === 'function' ? (u as (f: Filter[]) => Filter[])(columnFilters) : u),
enableSorting: true,
enableColumnFilters: true,
} as never),
)
const headerGroups = $derived(table.getHeaderGroups())
const rows = $derived(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).
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