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
can drive a minimal underlined table, a fully bordered grid, or floating row
cards. Flip the controls in the demo; the engine and rows are identical, only
the CSS changes.
The mindset
The engine hands you getHeaderGroups() and getRowModel().rows; how you turn
them into DOM - <table>, CSS grid, flexbox, divs - and how you style them is
entirely yours. Nothing below is prescribed; it's just what the demo does.
Track the site theme with --sg-* tokens
The grid's theming is a set of CSS custom properties (--sg-bg, --sg-fg,
--sg-border, --sg-header-bg, --sg-muted, --sg-accent, …). Use the same
tokens in your headless CSS and your table automatically follows the app's
light / dark theme and any per-instance overrides - no extra work.
.my-table { color: var(--sg-fg, #0f172a); font-size: 13px; }
.my-table th {
background: var(--sg-header-bg, #f1f5f9);
border-bottom: 2px solid var(--sg-border, #e2e8f0);
}
.my-table td { border-bottom: 1px solid var(--sg-border, #eef2f7); }
Always keep a literal fallback (var(--sg-fg, #0f172a)) so the table is styled
even outside a themed container. See Tailwind & theming for the
full token list.
The usual affordances
These are one CSS rule each - the engine doesn't need to know about them.
/* Zebra striping */
.my-table tbody tr:nth-child(even) td {
background: color-mix(in oklab, var(--sg-muted) 8%, transparent);
}
/* Hover */
.my-table tbody tr:hover td {
background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent);
}
/* Sticky header inside a scroll container */
.my-table thead th { position: sticky; top: 0; z-index: 1; }
/* Right-align numbers */
.my-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
/* Density: swap a class */
.dense th, .dense td { padding: 5px 10px; }
Sort affordance
Sorting is engine state, but the indicator is your markup - render it off the
sorting state you already control:
<th onclick={() => toggleSort(h.column.id)}>
{h.column.columnDef.header}
{#if sorting[0]?.id === h.column.id}
<span class="ind">{sorting[0].desc ? '▼' : '▲'}</span>
{/if}
</th>
Prefer Tailwind? Use classes instead of CSS
Because it's your markup, utility classes work exactly as they would on any
<table> - no wrapper, no :global:
<table class="w-full text-sm">
<thead>
<tr class="border-b-2 border-slate-200">
{#each headers as h}
<th class="px-3 py-2 text-left font-semibold cursor-pointer hover:text-indigo-500">
{h.column.columnDef.header}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as r}
<tr class="border-b border-slate-100 even:bg-slate-50 hover:bg-indigo-50">
<!-- cells -->
</tr>
{/each}
</tbody>
</table>
A styled table, end to end
Everything above, applied at once: tokens with literal fallbacks, zebra striping, a sticky header, right-aligned numerals. The engine below is the same five lines as the unstyled version.
<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
})
</script>
<div class="scroller">
<table class="my-table">
<thead>
{#each table.getHeaderGroups() as hg (hg.id)}
<tr>
{#each hg.headers as h (h.id)}
<th
class:num={h.column.id === 'stars'}
onclick={h.column.getToggleSortingHandler()}
>
{h.column.columnDef.header}
{#if sorting[0]?.id === h.column.id}
<span class="ind">{sorting[0].desc ? 'v' : '^'}</span>
{/if}
</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 class="num">{repo.stars.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.scroller { max-height: 220px; overflow: auto; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 8px; }
.my-table { width: 100%; border-collapse: collapse; color: var(--sg-fg, #0f172a); font-size: 13px; }
.my-table th {
position: sticky; top: 0; z-index: 1; text-align: left; cursor: pointer;
padding: 8px 12px; background: var(--sg-header-bg, #f1f5f9);
border-bottom: 2px solid var(--sg-border, #e2e8f0);
}
.my-table td { padding: 7px 12px; border-bottom: 1px solid var(--sg-border, #eef2f7); }
.my-table tbody tr:nth-child(even) td { background: color-mix(in oklab, var(--sg-muted, #64748b) 8%, transparent); }
.my-table tbody tr:hover td { background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent); }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.ind { font-size: 10px; opacity: 0.65; }
</style>
Same engine, no table at all
The strongest argument for headless is that the row model does not care what
you render. Identical createSvGrid call, identical rows - cards instead of a
<table>. A grid component cannot do this without a card mode; here it is just
different markup.
<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: '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>
<button type="button" onclick={() => (sorting = [{ id: 'name', desc: false }])}>
Sort by name
</button>
<div class="cards">
{#each rows as r (r.id)}
{@const repo = r.original as Repo}
<article class="card">
<h4>{repo.name}</h4>
<p class="lang">{repo.lang}</p>
<p class="stars">{repo.stars.toLocaleString()} stars</p>
</article>
{/each}
</div>
<style>
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-top: 10px; }
.card {
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; padding: 10px 12px;
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
}
.card h4 { margin: 0 0 4px; font-size: 13px; }
.lang { margin: 0; font-size: 11px; color: var(--sg-muted, #64748b); }
.stars { margin: 6px 0 0; font-size: 12px; font-variant-numeric: tabular-nums; }
</style>
Your design system's table primitives
If your team already has Table components - shadcn-svelte's Table.Root /
Table.Row / Table.Cell, or your own - the engine slots under them
unchanged. The header loop and the row loop are the same as above; only the
elements differ. This is the layout the
shadcn data-table migration lands
on when it keeps the shadcn markup and swaps the engine.
<script lang="ts">
import * as Table from '$lib/components/ui/table'
import {
createSvGrid,
createCoreRowModel,
createSortedRowModel,
tableFeatures,
rowSortingFeature,
type ColumnDef,
} from '@svgrid/grid/core'
type Payment = { id: string; status: string; email: string; amount: number }
let { payments }: { payments: Payment[] } = $props()
const features = tableFeatures({ rowSortingFeature })
const columns: ColumnDef<typeof features, Payment>[] = [
{ field: 'status', header: 'Status' },
{ field: 'email', header: 'Email' },
{ field: 'amount', header: 'Amount', editorType: 'number' },
]
let sorting = $state([{ id: 'amount', desc: true }])
const table = createSvGrid({
_features: features,
_rowModels: { coreRowModel: createCoreRowModel<Payment>(), sortedRowModel: createSortedRowModel<Payment>() },
data: payments,
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.Root>
<Table.Header>
{#each table.getHeaderGroups() as hg (hg.id)}
<Table.Row>
{#each hg.headers as h (h.id)}
<Table.Head onclick={h.column.getToggleSortingHandler()}>
{h.column.columnDef.header}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each rows as r (r.id)}
<Table.Row>
<Table.Cell>{r.original.status}</Table.Cell>
<Table.Cell>{r.original.email}</Table.Cell>
<Table.Cell class="text-right">{r.original.amount.toLocaleString()}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
Nothing from @svgrid/grid reaches the DOM here: no stylesheet, no class
names, no --sg-* variable unless you choose to read one. The design system
owns the markup and the engine owns the state, which is the split a
design-system team usually wants.
Keyboard navigation and ARIA
Headless does not mean you lose the grid semantics; it means you apply them.
The engine keeps an activeCell in its state and moves it with
moveActiveCell({ rowDelta, colDelta }), and @svgrid/grid/core exports the
WAI-ARIA attribute factories the render component uses, as plain objects to
spread onto your own elements. Wire the four of them and arrow keys, Home,
End, aria-sort and aria-activedescendant all work on a bare <table>.
<script lang="ts">
import {
createSvGrid,
createCoreRowModel,
createSortedRowModel,
tableFeatures,
rowSortingFeature,
getGridRootA11yProps,
getGridHeaderA11yProps,
getGridRowA11yProps,
getGridCellA11yProps,
getGridCellDomId,
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: 'esbuild', lang: 'Go', stars: 38000 },
{ name: 'bun', lang: 'Zig', stars: 74000 },
]
const features = tableFeatures({ rowSortingFeature })
const columns: ColumnDef<typeof features, Repo>[] = [
{ field: 'name', header: 'Repo' },
{ field: 'lang', header: 'Language' },
{ field: 'stars', header: 'Stars', editorType: 'number' },
]
let sorting = $state([{ id: 'stars', desc: true }])
// The selector picks the slices the template reads; `table.state` is the
// reactive view of them.
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),
},
(s) => ({ activeCell: s.activeCell as { rowIndex: number; colIndex: number } }),
)
// 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 active = $derived(table.state.activeCell)
const GRID_ID = 'repos'
const activeId = $derived(getGridCellDomId(GRID_ID, active.rowIndex, active.colIndex))
function onKeydown(e: KeyboardEvent) {
const moves: Record<string, { rowDelta?: number; colDelta?: number }> = {
ArrowDown: { rowDelta: 1 }, ArrowUp: { rowDelta: -1 },
ArrowRight: { colDelta: 1 }, ArrowLeft: { colDelta: -1 },
Home: { colDelta: -columns.length }, End: { colDelta: columns.length },
PageDown: { rowDelta: rows.length }, PageUp: { rowDelta: -rows.length },
}
const move = moves[e.key]
if (!move) return
e.preventDefault()
table.moveActiveCell(move)
}
function sortDirection(id: string) {
const s = sorting[0]
return s?.id !== id ? 'none' : s.desc ? 'descending' : 'ascending'
}
</script>
<table
class="kb"
{...getGridRootA11yProps({ activeDescendantId: activeId, rowCount: rows.length, colCount: columns.length })}
onkeydown={onKeydown}
>
<thead>
{#each table.getHeaderGroups() as hg (hg.id)}
<tr {...getGridRowA11yProps(1)}>
{#each hg.headers as h (h.id)}
<th
{...getGridHeaderA11yProps({ sortable: true, sortDirection: sortDirection(h.column.id) })}
onclick={h.column.getToggleSortingHandler()}
>
{h.column.columnDef.header}
</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each rows as r, ri (r.id)}
<tr {...getGridRowA11yProps(ri + 2)}>
{#each columns as c, ci (c.field)}
{@const selected = active.rowIndex === ri && active.colIndex === ci}
<td
{...getGridCellA11yProps({ rowIndex: ri + 2, colIndex: ci + 1, selected, id: getGridCellDomId(GRID_ID, ri, ci) })}
class:active={selected}
onclick={() => table.setActiveCell({ rowIndex: ri, colIndex: ci, cellId: `${ri}_${c.field}` })}
>
{String(r.original[c.field as keyof Repo])}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
<style>
.kb { width: 100%; border-collapse: collapse; font-size: 13px; color: var(--sg-fg, #0f172a); }
.kb:focus { outline: 2px solid var(--sg-accent, #6366f1); outline-offset: 2px; }
.kb th { text-align: left; padding: 8px 12px; background: var(--sg-header-bg, #f1f5f9); cursor: pointer; }
.kb th[aria-sort='ascending']::after { content: ' ^'; }
.kb th[aria-sort='descending']::after { content: ' v'; }
.kb td { padding: 7px 12px; border-bottom: 1px solid var(--sg-border, #eef2f7); }
.kb td.active { box-shadow: inset 0 0 0 2px var(--sg-accent, #6366f1); }
</style>
Click a cell, then use the arrow keys. The root carries role="grid",
tabindex="0" and aria-activedescendant; each header carries
aria-sort; each cell carries role="gridcell", aria-rowindex,
aria-colindex and aria-selected. Screen readers announce the same grid
the render component would. What you still own: focus styling, Enter to
edit if you add editing, and Tab to leave the grid (the browser handles
that one because the cells themselves are not focusable).
See also
- Build a table from scratch - the render loop
- Headless virtualization - style a virtualized list
- Tailwind & theming tokens - the
--sg-*reference - Migrating from the shadcn-svelte data table - keep the shadcn markup, swap the engine
- Keyboard and accessibility - what the render component does, for parity
Related articles
- SvGrid vs TanStack Table - A Deep Dive - A concrete architectural comparison of SvGrid and TanStack Table's Svelte adapter - how each handles reactivity, rendering, and feature composition, with code that shows exactly where they diverge.
- SvGrid vs svelte-headless-table - A practical comparison of SvGrid and svelte-headless-table covering reactivity model, rendering approach, feature scope, and when each is the right choice for a Svelte 5 project.
- Conditional Row Styling in SvGrid - Drive row-level background tints, classes, and styles from your data - overdue invoices, failed jobs, VIP records - without touching the DOM directly.