Getting Started with SvGrid

SvGrid is a modern, production-ready data grid for Svelte 5 - a headless core engine paired with a Svelte render component (<SvGrid>). It scales from a 10-row read-only table to a virtualized 100,000-row, 100-column editing surface with grouping, multi-column filtering, server-side data, and full keyboard and screen-reader support.

This page walks you from pnpm add to a feature-complete grid. It is the canonical entry point - every other page in the documentation assumes you've finished this one. Estimated reading time: 15 minutes.

New here? Two short companion reads:

  • Why headless? - the architecture decision behind the createSvGrid core vs. the <SvGrid> renderer.
  • Tailwind integration - how --sg-* custom properties + Tailwind v4 + dark mode fit together.

@svgrid/grid is published under the MIT License - permissive for commercial use, redistribution, and modification. The paid companion @svgrid/enterprise (data export + print) ships under a separate commercial license. See LICENSE and packages/enterprise/LICENSE.


Contents

  1. Your first grid in 60 seconds
  2. Install the package
  3. Provide row data
  4. Define column definitions
  5. Register features (row models)
  6. Styling: theme, density, dark mode
  7. Sizing the grid
  8. Custom cells with FlexRender
  9. Sorting, filtering, pagination
  10. Selection, editing, keyboard
  11. Server-side data
  12. Virtualization for large datasets
  13. Accessibility
  14. TypeScript notes
  15. What's next

1. Your first grid in 60 seconds

Every example below runs against this setup:

<script lang="ts">
  import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature, rowPaginationFeature, type ColumnDef } from '@svgrid/grid'

  type Person = { id: number; name: string; email: string; age: number; city: string }

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowPaginationFeature })

  let rows = $state<Person[]>([
    { id: 1, name: 'Ada Lovelace',   email: '[email protected]',   age: 36, city: 'London' },
    { id: 2, name: 'Grace Hopper',   email: '[email protected]', age: 45, city: 'New York' },
    { id: 3, name: 'Linus Torvalds', email: '[email protected]', age: 54, city: 'Portland' },
  ])
  const data = rows

  const columns: ColumnDef<typeof features, Person>[] = [
    { field: 'name',  header: 'Name' },
    { field: 'email', header: 'Email' },
    { field: 'age',   header: 'Age', width: 90 },
    { field: 'city',  header: 'City' },
  ]
</script>
<script lang="ts">
  import { SvGrid, type GridColumns } from '@svgrid/grid'

  type Person = { firstName: string; age: number; status: string }

  const rows: Person[] = [
    { firstName: 'Ada',   age: 36, status: 'active' },
    { firstName: 'Linus', age: 54, status: 'active' },
    { firstName: 'Grace', age: 85, status: 'inactive' },
  ]

  const columns: GridColumns<Person> = [
    { field: 'firstName', header: 'First name' },
    { field: 'age',       header: 'Age' },
    { field: 'status',    header: 'Status' },
  ]
</script>

<SvGrid data={rows} columns={columns} />

That's a complete, working grid. The rest of this page is about turning it into something you'd ship.


2. Install the package

SvGrid is a single npm package. There is no peer dependency on a CSS framework - bring your own, or use the bundled stylesheet.

# pnpm (recommended)
pnpm add @svgrid/grid

# npm
npm install @svgrid/grid

# yarn
yarn add @svgrid/grid

Requirements.

Once installed, import the component, the features you want, and the matching ColumnDef type:

import {
  SvGrid,
  tableFeatures,
  rowSortingFeature,
  columnFilteringFeature, type GridColumns,
} from '@svgrid/grid'

The bundle is tree-shakeable - features you don't import don't ship. The default render component (<SvGrid>) brings its own scoped CSS, so there's no separate stylesheet to import. Re-theming happens via the --sg-* custom-property surface; see Tailwind integration for the full list.


3. Provide row data

SvGrid is data-agnostic. The data prop is any ReadonlyArray<TRow> - a Svelte 5 $state array, a derived store, an SWR/React-query-style cache, the result of a +page.ts load function, or a plain literal.

<script lang="ts">
  import { SvGrid } from '@svgrid/grid'

  type Person = { id: string; firstName: string; age: number }

  // Reactive: pushing into `rows` updates the grid automatically.
  let rows = $state<Person[]>([
    { id: '1', firstName: 'Ada',   age: 36 },
    { id: '2', firstName: 'Linus', age: 54 },
  ])

  function addRow() {
    rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })
  }
</script>

<button onclick={addRow}>Add row</button>
<SvGrid data={rows} columns={columns} />

Identity. Today the wrapper uses the row's array index as its id. That is fine for read-only data; if you mutate rows, prefer keeping the same object references for rows that didn't change so selection and edit state line up. A getRowId prop on the wrapper is tracked in Missing features and supported by the headless createSvGrid core today.

Immutability. SvGrid never mutates your data. When you edit a cell the grid emits an event; you decide whether to mutate in place or copy. See §10 - Editing.


4. Define column definitions

A column definition tells SvGrid how to read a value out of a row, how to render it, and which features apply to it.

import type { ColumnDef } from '@svgrid/grid'

type Person = {
  id: string
  firstName: string
  lastName: string
  age: number
  joinedAt: string // ISO date
  salary: number
  active: boolean
}

const columns: GridColumns<Person> = [
  // `field`: read a key straight off the row
  { field: 'firstName', header: 'First name' },

  // `fieldFn`: compute the value; needs an explicit `id`
  {
    id: 'fullName',
    header: 'Full name',
    fieldFn: (row) => `${row.firstName} ${row.lastName}`,
  },

  // Numeric with locale-aware formatting
  {
    field: 'age',
    header: 'Age',
    format: { type: 'number', options: { maximumFractionDigits: 0 } },
  },

  // Date with explicit pattern
  {
    field: 'joinedAt',
    header: 'Joined',
    format: { type: 'date', pattern: 'y-m-d' },
  },

  // Currency
  {
    field: 'salary',
    header: 'Salary',
    format: { type: 'currency', currency: 'USD' },
  },

  // Boolean rendered as a checkbox
  {
    field: 'active',
    header: 'Active',
    editorType: 'checkbox',
  },
]

Common properties.

Property Purpose
field Reads row[key].
fieldFn Computes the value from the row.
id Stable column id (required if you use fieldFn).
header String or render snippet for the header.
footer String or render snippet for the footer row.
cell Render snippet/component for the body cell.
format Locale-aware formatter (number, currency, percent, date).
formatter Function for one-off custom value formatting.
editorType Inline editor: text | number | checkbox | date | datetime.
width Initial column width in pixels (default columnWidth prop).
align Header + body alignment: 'left' | 'right' | 'center'. Inferred from editorType when omitted.
columns Child column defs (for column groups).

Sorting / filtering / grouping are toggled per-grid via the registered features - there is no per-column enableSorting / enableColumnFilter flag yet; those entries are in Missing features.

See packages/grid/src/core.ts for the full type.


5. Register features (row models)

The grid engine is feature-gated. Out of the box you get the core row model (the rows in their original order). To enable sorting, filtering, grouping, expansion, pagination, or selection you opt in with tableFeatures(...) and the matching create*RowModel factory.

<script lang="ts">
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature, type GridColumns,
  } from '@svgrid/grid'

  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
  })
</script>

<SvGrid
  data={rows}
  columns={columns}
  features={features}
  showPagination={true}
  pageSize={25}
/>

Rule of thumb. Only register the features you use. The wrapper wires the matching row-model pipeline (core → filtered → sorted → grouped → expanded) for you and exposes the user-facing toggles via props (showPagination, filterMode, showRowSelection, …). If you need the headless pipeline directly - e.g. a custom renderer - drop down to createSvGrid from the same package; see Why headless?.

Feature Factory What it does
rowSortingFeature createSortedRowModel Click headers to sort; shift-click for multi-sort.
columnFilteringFeature createFilteredRowModel Per-column filters with built-in filterFns.
rowPaginationFeature createPaginatedRowModel Page slicing + footer state.
rowSelectionFeature - Row checkboxes, range selection, headless API.
columnGroupingFeature createGroupedRowModel Group-by-column + aggregators.
rowExpandingFeature createExpandedRowModel Tree / master-detail expansion.

6. Styling: theme, density, dark mode

SvGrid's render component (<SvGrid>) ships its own scoped styles. You re-theme it by declaring --sg-* custom properties at any level above the grid - the included light, dark, and high-contrast palettes in the 10-custom-cells-and-themes demo are themselves just three sets of these tokens applied via style="--sg-bg: …; --sg-fg: …; …".

Customising tokens. Override at any level - :root, a wrapper, or directly on <SvGrid>.

:root {
  --sg-header-bg: #f6f7f9;
  --sg-header-fg: #1f2933;
  --sg-row-hover-bg: #eef2ff;
  --sg-selection-bg: #dbeafe;
  --sg-border: #e5e7eb;
  --sg-focus-ring: 0 0 0 2px #2563eb;
  --sg-font: 'Inter', system-ui, sans-serif;
}

@media (prefers-color-scheme: dark) {
  :root {
    --sg-header-bg: #0f172a;
    --sg-header-fg: #f1f5f9;
    --sg-row-hover-bg: #1e293b;
    --sg-border: #334155;
  }
}

Density. Row height is the one piece of the look that is a prop rather than a token - the virtualizer needs it as a number, and the grid writes it as an inline style on each row. Pass rowHeight (default 30): 28 for compact, 48 for comfortable. Changing it does not remount the virtualizer.

Reduced motion. Sort animations and expand transitions respect prefers-reduced-motion: reduce automatically.


7. Sizing the grid

The grid's scroll shell is sized by containerHeight, which defaults to 520 px. A number is pixels; a string is passed through to CSS, so '100%' fills the parent and 'auto' grows to the content.

<!-- Fixed: 600px tall. The typical choice. -->
<SvGrid data={rows} columns={columns} containerHeight={600} />

<!-- Flexible: fills a parent that sets the height itself. -->
<div class="grid-shell">
  <SvGrid data={rows} columns={columns} containerHeight="100%" />
</div>

<style>
  .grid-shell {
    height: calc(100dvh - 4rem);
  }
</style>

Auto-height (small datasets only). For grids with fewer than ~200 rows you can let the grid grow to its content. Turn row virtualization off as well - a shell with no fixed height gives the virtualizer nothing to measure against:

<SvGrid
  data={rows}
  columns={columns}
  containerHeight="auto"
  virtualization={false}
/>

Don't use this for large data: every row renders.


8. Custom cells with FlexRender

For anything beyond a stringified value, render with FlexRender, renderComponent, or renderSnippet.

As a Svelte snippet

<script lang="ts">
  import { renderSnippet, type GridColumns } from '@svgrid/grid'
</script>

{#snippet StatusCell({ value }: { value: string })}
  <span class="pill pill-{value}">{value}</span>
{/snippet}

<script lang="ts">
  const columns: GridColumns<Person> = [
    {
      field: 'status',
      header: 'Status',
      cell: renderSnippet(StatusCell, (ctx) => ({ value: ctx.getValue() as string })),
    },
  ]
</script>

As a Svelte component

import StatusBadge from './StatusBadge.svelte'
import { renderComponent } from '@svgrid/grid'

const columns = [
  {
    field: 'status',
    header: 'Status',
    cell: renderComponent(StatusBadge, (ctx) => ({ status: ctx.getValue() })),
  },
]

renderComponent and renderSnippet both receive a CellContext so you can read sibling values, mutate state, or call back into the grid via ctx.table.


9. Sorting, filtering, pagination

Once their features are registered (see §5) the UI affordances appear automatically. The state is controllable.

Quick way - capability shortcuts. Every capability is off by default; the fastest way to opt in is a boolean shortcut prop, no feature constants required. sortable and filterable inject the matching feature for you; editable, groupable, and pageable alias enableInlineEditing, showGroupingControls, and showPagination:

<SvGrid data={rows} columns={columns} sortable filterable editable groupable pageable />

Reach for the explicit features set + fine-grained props below when you need more control (filter mode, page size, per-column opt-outs).

Uncontrolled (the default)

The wrapper owns sort, filter, pagination, selection, and expansion state by default. Set the initial page size and which filter UI to show via props:

<SvGrid
  data={rows}
  columns={columns}
  features={features}
  showPagination={true}
  pageSize={50}
  filterMode="menu"
/>

Observable (callbacks fire when state changes)

The wrapper still owns the state, but emits callbacks on every change. Use this when an outside piece of UI needs to react (a "X rows selected" pill, a router that syncs sort to the URL, a server fetch).

<script lang="ts">
  let sorting = $state<Array<{ id: string; desc: boolean }>>([])
  let filters = $state<Array<{ id: string; operator: string; value: string }>>([])
</script>

<SvGrid
  data={rows}
  columns={columns}
  features={features}
  filterMode="menu"
  onSortingChange={(next) => (sorting = next)}
  onFiltersChange={(next) => (filters = next.columns)}
/>

External (you own row ordering / filtering)

For server-side data or tree-structured data the wrapper records the sort + filter UI state but does not re-order the rows - you do. Pair externalSort / externalFilter with the callbacks above:

<SvGrid
  data={preFilteredRows}
  columns={columns}
  features={features}
  filterMode="menu"
  externalSort={true}
  externalFilter={true}
  onSortingChange={(next) => fetchPage({ sort: next, page: 0 })}
  onFiltersChange={(next) => fetchPage({ filters: next.columns, page: 0 })}
/>

For Excel-style filter operators and the active-filter chip UI, see applyExcelFilter.


10. Selection, editing, keyboard

Row selection

<script lang="ts">
  let selected = $state<Person[]>([])
</script>

<SvGrid
  data={rows}
  columns={columns}
  features={features}
  selectionMode="row"
  showRowSelection={true}
  onRowSelectionChange={(_state, rows) => (selected = rows)}
/>

{#if selected.length}
  <p>{selected.length} selected</p>
{/if}

selectionMode is the umbrella prop: 'row' shows the checkbox column, 'cell' enables click-and-drag range selection, 'both' (default) enables both, 'none' disables both. The onRowSelectionChange callback receives the selection record AND the materialised row array.

Cell editing

Set editorType on each editable column. The grid handles entry, commit, and cancel; you handle persistence.

<script lang="ts">
  function handleCellEdit(event: {
    rowId: string
    columnId: string
    value: unknown
  }) {
    const row = rows.find((r) => r.id === event.rowId)
    if (!row) return
    ;(row as Record<string, unknown>)[event.columnId] = event.value
  }
</script>

<SvGrid
  data={rows}
  columns={columns}
  getRowId={(row) => row.id}
  onCellValueChange={handleCellEdit}
/>

Keyboard

The grid follows the WAI-ARIA grid pattern:

Keys Action
Move active cell
Home / End First / last column of the row
Ctrl+Home / Ctrl+End First / last cell of the grid
PageUp / PageDown Move one viewport
Shift + <move> Extend cell-range selection
Space Toggle row selection (when selection enabled)
Enter / F2 Begin editing the active cell
Esc Cancel edit / clear selection
Ctrl/Cmd + C Copy selection as TSV
Ctrl/Cmd + V Paste TSV into selection

If you implement your own header or toolbar, route keys through getKeyboardIntent and getNextActiveCell so behaviour stays consistent.


11. Server-side data

For datasets that don't fit in memory, drive the grid from the server. The pattern is: turn the wrapper's onSortingChange / onFiltersChange callbacks into a query, fetch, hand the page back as data, and use the externalSort + externalFilter props so the grid doesn't try to re-order rows it didn't fetch.

<script lang="ts">
  import { SvGrid, tableFeatures, rowSortingFeature,
           columnFilteringFeature } from '@svgrid/grid'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  let sort    = $state<Array<{ id: string; desc: boolean }>>([])
  let filters = $state<Array<{ id: string; operator: string; value: string }>>([])
  let page    = $state(0)
  const pageSize = 50

  let rows    = $state<Person[]>([])
  let total   = $state(0)
  let loading = $state(false)
  let controller: AbortController | null = null

  async function load() {
    controller?.abort()
    controller = new AbortController()
    loading = true
    try {
      const res = await fetch('/api/people?' + new URLSearchParams({
        sort:    JSON.stringify(sort),
        filters: JSON.stringify(filters),
        page:    String(page),
        size:    String(pageSize),
      }), { signal: controller.signal })
      const body = await res.json()
      rows  = body.rows
      total = body.total
    } catch (err) {
      if ((err as Error).name !== 'AbortError') throw err
    } finally {
      loading = false
    }
  }

  $effect(() => { sort; filters; page; load() })
</script>

<SvGrid
  data={rows}
  columns={columns}
  features={features}
  filterMode="menu"
  externalSort={true}
  externalFilter={true}
  showPagination={false}
  onSortingChange={(next) => { sort = next; page = 0 }}
  onFiltersChange={(next) => { filters = next.columns; page = 0 }}
/>

<nav>
  <button onclick={() => (page = Math.max(0, page - 1))}
          disabled={page === 0 || loading}>‹ Prev</button>
  <span>Page {page + 1} of {Math.ceil(total / pageSize)}</span>
  <button onclick={() => (page = page + 1)}
          disabled={(page + 1) * pageSize >= total || loading}>Next ›</button>
</nav>

{#if loading}<div class="overlay">Loading…</div>{/if}

The external* props tell the grid not to re-derive that dimension locally - the data you pass in is already the answer. Pagination above is hand-rolled so total-row-count and "show next page" stay in your control; if a built-in pager is enough, leave showPagination={true} on and the wrapper will page the local rows array (which, in this server-side pattern, only ever holds one page anyway).

See the 09-server-side demo for a complete runnable version with debounce, abort wiring, and a 60 ms mock latency.


12. Virtualization for large datasets

Row and column virtualization are both on by default - you opt out, not in. virtualization={false} and columnVirtualization={false} render every row / column, which is what you want for a short grid you intend to print, or when sticky column pinning has to survive (the column virtualizer recycles DOM nodes, so the two cannot co-exist).

<script lang="ts">
  import { SvGrid } from '@svgrid/grid'
</script>

<SvGrid
  data={rows}
  columns={columns}
  rowHeight={36}
  overscan={6}
/>

rowHeight takes a number (default 30) or a (rowIndex) => px function for per-row heights. When row content varies and you cannot predict it, set autoRowHeight instead and the grid measures each row. overscan (default 8) is how many rows beyond the viewport stay mounted.

For full control (e.g. variable row heights, programmatic scroll), use the headless virtualizer directly:

import { createSvelteVirtualizer } from '@svgrid/grid'

const virtualizer = createSvelteVirtualizer({
  count:                 () => rows.length,
  getScrollElement:      () => scrollRef,
  estimateSize:          (index) => (rows[index].kind === 'header' ? 40 : 28),
  overscan:              6,
})

// Programmatic scroll:
virtualizer.scrollToIndex(75_432, { align: 'center' })

See packages/grid/src/virtualization/ for the full API.


13. Accessibility

SvGrid implements the WAI-ARIA 1.2 grid pattern.

If you build your own header or toolbar, use the helpers in a11y.ts so your markup stays consistent with the contract:

import {
  getGridRootA11yProps,
  getGridRowA11yProps,
  getGridCellA11yProps,
  getGridHeaderA11yProps,
} from '@svgrid/grid'

There is a contract test suite at a11y.contract.test.ts that exercises the public a11y guarantees - run it (pnpm test) when you customize markup to be sure you haven't regressed the contract.


14. TypeScript notes

Most APIs are generic over your row type. Define the row type once and flow it through:

type Person = { id: string; firstName: string; age: number }

const columns: GridColumns<Person> = [
  { field: 'firstName', header: 'First name' }, // ✅ key checked
  // { field: 'first_name', header: '…' },      // ✗ TS error
]

The first type parameter is the feature set. When you register features, derive it once and reuse:

const features = tableFeatures({
  rowSortingFeature,
  rowSelectionFeature,
  columnFilteringFeature,
})

type Features = typeof features

const columns: ColumnDef<Features, Person>[] = [/* … */]

This lets feature-specific column properties (like filterFn) auto-complete and type-check.


15. What's next

Core features

Enterprise features (@svgrid/enterprise)

The paid companion package augments your SvGridApi with one installEnterprise(api) call. Set a license key at app boot to remove the "unlicensed" watermark - every feature still runs without a key for demos and evaluation.

Getting help

License

@svgrid/grid is published under the MIT License. Free for commercial and personal use. The paid @svgrid/enterprise companion package (export, import, print, pivot, AI assistant) is governed by a separate commercial license. See LICENSE and packages/enterprise/LICENSE.

Live examples

  • Custom cells + themes - Avatars, sparklines, progress bars, density toggle, dark mode, full a11y.
  • Server-side data - Sort/filter/page round-tripped to a mock endpoint with debounce + cancel.

Related articles