Svelte Headless Table
A headless table is the part of a data grid that is not the DOM: which rows are visible after filtering, in what order after sorting, which are selected, which group is expanded, which cell is active. SvGrid ships that part on its own as @svgrid/grid/core. You call createSvGrid with data and columns, read rows and header groups back, and render them with whatever markup your design system already has.
Nothing on this subpath touches the DOM, ARIA or CSS. There is no stylesheet to override and no class name to fight, which is the reason platform teams with a strict internal component library reach for it: the engine owns the state, the design system owns the markup, and the two do not argue. The WAI-ARIA attribute factories and the virtualizers are exported alongside the engine, so an accessible, windowed table over 50,000 rows is still yours to draw.
The same engine drives the render-ready <SvGrid> component, so a team can start headless on the screens the design system covers and drop the component in where it does not. One state machine, two renderers, and the measured size of the engine alone is printed below rather than typed.
Install
npm i @svgrid/gridFree and MIT-licensed in @svgrid/grid: no license key, no row cap, no watermark.
Measured: 2.6 KB gzipped JS, no stylesheet, minified with Svelte external, at @svgrid/grid 3.0.4 on 2026-09-20. Reproduce it with pnpm size:json; the method is on the bundle size reference.
The code
<script lang="ts">
import {
createSvGrid, createCoreRowModel, createSortedRowModel,
tableFeatures, rowSortingFeature, type ColumnDef,
} from '@svgrid/grid/core'
const features = tableFeatures({ rowSortingFeature })
const columns: ColumnDef<typeof features, Person>[] = [
{ field: 'name', header: 'Name' },
{ field: 'age', header: 'Age', editorType: 'number' },
]
let sorting = $state([{ id: 'age', desc: true }])
const table = createSvGrid({
_features: features,
_rowModels: { coreRowModel: createCoreRowModel(), sortedRowModel: createSortedRowModel() },
data, columns,
state: { sorting },
onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),
})
const rows = $derived(table.getRowModel().rows)
</script>
<table class="your-table">
<thead>
{#each table.getHeaderGroups() as hg (hg.id)}
<tr>
{#each hg.headers as h (h.id)}
<th onclick={h.column.getToggleSortingHandler()}>{h.column.columnDef.header}</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each rows as r (r.id)}
<tr><td>{r.original.name}</td><td>{r.original.age}</td></tr>
{/each}
</tbody>
</table>What you get
- The engine does - Sorting, filtering, grouping with aggregates, pagination, expansion, selection and the active cell, as a row-model pipeline you opt into stage by stage.
- You do - The elements, the class names, the tokens and the editors. A <table>, a CSS grid, shadcn-svelte Table primitives or a list of cards all read the same rows.
- Virtualization included - createSvelteVirtualizer turns a scroll offset into the visible window and a spacer height; 50,000 rows keep only the rows in view in the DOM.
- Accessible on your markup - getGridRootA11yProps, getGridHeaderA11yProps, getGridRowA11yProps and getGridCellA11yProps return the WAI-ARIA attributes to spread onto your own elements; moveActiveCell drives the keyboard.
- Runs without a browser - createSvGridCore is the same engine with no runes, so row models unit-test in plain Node and the sorted, filtered result can be computed on a server.
- Measured, not estimated - The size on this page is written by pnpm size:json on every release and checked in CI; the number in the docs is the number in the build.
Live examples
- Headless -> your own table - No <SvGrid>: the createSvGrid engine sorts + filters, and this component renders a plain, hand-styled <table>. The engine does the logic; you own the markup.
- Headless virtualization - 50,000 rows, headless. createSvelteVirtualizer reports the visible slice; the markup is hand-written in a custom scroll container.
- Styling a headless table - You own every pixel. Same engine, three looks - flip preset (minimal / bordered / card), density, and zebra striping. --sg-* tokens keep it in sync with the site theme.
- Two grids, one shared state - createGridState returns a [get, set] tuple - a reactive store you own. Feed it to two createSvGrid engines and they stay in lockstep.
- Row models are a pipeline - Flip the group-by control and watch the pipeline change shape: core -> grouped -> expanded. Group rows carry the aggregate: sum roll-up; the markup is a plain hand-styled <table>.
- Headless server-side - Paging + sorting + filtering + load on demand. The "server" owns the data and returns one page at a time; each state change fires a single request. The engine wraps only the current page.
Documentation
- Why headless? - SvGrid is headless at the core, with a fully-styled Svelte component shipped on top. That two-layer split is deliberate, and worth understanding before you…
- Headless overview - <SvGrid> is the renderer. createSvGrid is the engine that powers it. They're independent: you can use the engine on its own to build a custom UI, render a…
- 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…
- Styling a headless table - With the headless engine you render your own markup, so you own every pixel of styling - there is no grid stylesheet to fight. The same createSvGrid engine…
- Headless virtualization - <SvGrid> virtualizes rows and columns for you. When you render your own markup, you virtualize yourself with the same engine SvGrid uses -…
- Bundle size - What SvGrid costs in your bundle, how to reproduce the number on your branch, and what to do if size matters.
Related articles
- Build vs Buy - Should You Build Your Own Svelte Data Table? - A working engineer's breakdown of when a hand-rolled Svelte table is the right call, when it will cost you three sprints, and what the headless middle path actually looks like in code.
- Bundle Size of Svelte Data Grids - How to Compare - README bundle numbers are nearly useless. Here is how to measure the real delta a data grid adds to your Svelte app, and why feature-gated architectures change the math entirely.
- Why We Bet on Svelte 5 Runes for a High-Performance Data Grid - Fine-grained reactivity with $state, $derived, and $effect makes surgical cell updates the default, not the exception - and that changes how you build a data grid.
Frequently asked questions
What is a headless table in Svelte?
A table engine without a renderer: it computes which rows to show, in what order, with what selection and grouping state, and hands you plain data to render. SvGrid exposes its engine as @svgrid/grid/core for exactly this use.
How is this different from a headless-only table library?
The engine is the same idea. The difference is that SvGrid also ships a render-ready <SvGrid> component over the same state machine, so you can go headless where your design system demands its own markup and use the component everywhere else, without two libraries.
Does headless mean no accessibility?
No. The core entry exports the WAI-ARIA attribute factories the render component uses, plus an activeCell state and moveActiveCell for keyboard navigation. Spread the attributes onto your own elements and screen readers announce the same grid.
Can I use it with shadcn-svelte or my own component library?
Yes. The engine returns rows and header groups; render them with Table.Root, Table.Row and Table.Cell or any elements you like. The headless styling guide shows the shadcn-svelte layout and a keyboard-navigable bare table.
How big is the headless engine?
The measured gzipped size of the createSvGrid entry is printed on this page from the size ledger the build writes, with the version and the date it was measured. The full render component is a separate figure on the bundle size page.