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 plain <table> for print or email, run the row pipeline in a Web Worker or on a server, or unit-test sort / filter / aggregation logic with no DOM at all.

The createSvGrid engine holds state and prop-getters, while the <SvGrid> renderer is one opinionated table built on that same engine.

your data ─▶ createSvGrid (engine) ─▶ row model ─▶ your markup
                     ▲                                  │
                controlled state  ◀──── change events ──┘

What the engine gives you

createSvGrid(options) returns a table object with:

No DOM, no CSS, no virtualization - those live in the renderer. Here's the engine rendering a plain, hand-styled <table> (sort + filter are the engine's; the markup is the demo's):

Open the live example: Headless -> your own table (Headless)

When to reach for headless

You want to… Use
A rich grid in a Svelte app <SvGrid> (start here)
Render as a plain <table> (print / email / RSC) Headless
Drive a server-side row model from Node Headless, via createSvGridCore
Unit-test sort / filter / aggregator logic Headless
Build a custom virtualized renderer Headless + the virtualizer exports
Share one state object across two grids Headless + createGridState

For the common case, use <SvGrid> - it wires all of this for you. Reach for the engine when you need a different renderer or to run the pipeline where there is no DOM.

The three ideas

  1. Row models are a pipeline. You opt into the steps you need (coreRowModel, filteredRowModel, sortedRowModel, …); unused steps are tree-shaken. See Row models.
  2. State is controlled. You pass state in and get onXxxChange events out - the engine never mutates your state. This is what makes it click with Svelte 5 $state. See Controlled state.
  3. Rendering is yours. The engine hands you rows + header groups; you emit the markup. See Build a table from scratch.

createSvGrid vs createSvGridCore

Both build the same engine and expose the same getRowModel() / getHeaderGroups() surface. The difference is reactivity:

createSvGrid createSvGridCore
State Svelte 5 runes plain objects
Needs the Svelte compiler Yes No
Runs under Vite, SvelteKit, vitest anywhere Node runs

Inside a component, use createSvGrid - runes are what make $derived re-run the pipeline when your state changes. Outside one - a Node service, a worker, a CLI, a plain unit test with no Svelte in the pipeline - use createSvGridCore and rebuild it yourself when the state changes:

// plain node script.mjs - no bundler, no compiler
import {
  createSvGridCore,
  createCoreRowModel,
  createSortedRowModel,
  tableFeatures,
  rowSortingFeature,
} from '@svgrid/grid/core'

const features = tableFeatures({ rowSortingFeature })
const table = createSvGridCore({
  _features: features,
  _rowModels: {
    coreRowModel: createCoreRowModel(),
    sortedRowModel: createSortedRowModel(),
  },
  data,
  columns,
  state: { sorting: [{ id: 'salary', desc: true }] },
  onSortingChange: () => {},
})

const rows = table.getRowModel().rows   // sorted, no DOM involved

createSvGrid imported into a bare Node process throws ReferenceError: $state is not defined - that is the compiler missing, not a bug. Reach for the core function there.

The engine with no grid

No <SvGrid> anywhere. The engine owns sorting and hands back a row model; every element below is markup you wrote, which is the whole proposition.

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

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

<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)}
      {@const repo = r.original as Repo}
      <tr>
        <td>{repo.name}</td>
        <td>{repo.lang}</td>
        <td>{repo.stars.toLocaleString()}</td>
      </tr>
    {/each}
  </tbody>
</table>

Adding a stage

Each row model is a pipeline stage you opt into. Register the filtered model and a filter starts applying; leave it out and the code for it never ships.

<script lang="ts">
  import {
    createSvGrid,
    createCoreRowModel,
    createFilteredRowModel,
    tableFeatures,
    columnFilteringFeature,
    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 },
  ]

  const features = tableFeatures({ columnFilteringFeature })

  const columns: ColumnDef<typeof features, Repo>[] = [
    { field: 'name',  header: 'Repo' },
    { field: 'lang',  header: 'Language' },
  ]

  let columnFilters = $state<Array<{ id: string; value: unknown }>>([])

  const table = createSvGrid({
    _features: features,
    _rowModels: {
      coreRowModel: createCoreRowModel<Repo>(),
      filteredRowModel: createFilteredRowModel<Repo>(),
    },
    data,
    columns,
    state: { columnFilters },
    onColumnFiltersChange: (u) =>
      (columnFilters = typeof u === 'function' ? u(columnFilters) : 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(() => {
    columnFilters
    return table.getRowModel().rows
  })
</script>

<input
  placeholder="Filter language"
  oninput={(e) => (columnFilters = [{ id: 'lang', value: e.currentTarget.value }])}
/>

<ul>
  {#each rows as r (r.id)}
    {@const repo = r.original as Repo}
    <li>{repo.name} - {repo.lang}</li>
  {/each}
</ul>

See also

Related articles