Custom cells + themes
Avatars, sparklines, progress bars, density toggle, dark mode, full a11y.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Rows & Cells). See the SvGrid documentation for the full API.
About this example
Custom cell content and theming in the Svelte 5 data grid: avatars, sparklines and progress bars rendered with renderSnippet, a density toggle driven purely by CSS custom properties and the rowHeight prop, and a forced light, dark or high-contrast theme switch. ARIA roles and focus styles come from the grid's built-in accessibility helpers and need no re-declaration.
Demonstrates renderSnippet for custom cell content, a density toggle driven entirely by CSS custom properties, and a forced light/dark/ high-contrast theme switch. ARIA roles & focus styles come from the grid's built-in a11y helpers - they do not need to be re-declared here.
Imports, features and API used
Imports: @svgrid/grid, ../shared/seed
Table features registered: rowSortingFeature
Frequently asked questions
How do I render a Svelte snippet in a cell?
Define a snippet in the component and return renderSnippet(MySnippet, props) from the column's cell function. The snippet receives the row and renders any markup, here an avatar, a sparkline or a progress bar.
How is the density toggle implemented?
The toggle changes rowHeight and a few --sg-* custom properties such as cell padding. Nothing in the grid is re-created; the CSS variables do the work.
Do I need to add ARIA roles to custom cells?
No. The grid's cells already carry the grid roles and focus handling; the snippet renders inside that cell, so keyboard navigation and screen reader announcements keep working.
Related documentation
Related articles
- Sparkline Cells in a Svelte Data Grid - Show inline trend sparklines inside grid cells using SvGrid's built-in sparkline column property - no charting library needed, just a field that holds a number array.
- Progress and Percentage Bar Cells in SvGrid - Build in-cell progress bars in your Svelte 5 data grid - with color thresholds, accessible markup, and sorting that still works.
- A Custom Column Header Menu in SvGrid - Build a per-column header menu for sort, hide, pin, and custom actions using header snippets and your own dropdown component.
Source code (10-custom-cells-and-themes.svelte)
<script lang="ts">
/**
* 10. Custom cells + themes
* -------------------------
* Demonstrates `renderSnippet` for custom cell content, a density toggle
* driven entirely by CSS custom properties, and a forced light/dark/
* high-contrast theme switch. ARIA roles & focus styles come from the
* grid's built-in a11y helpers - they do not need to be re-declared here.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
renderSnippet,
type GridColumns,
} from '@svgrid/grid'
import { makePeople, type Person } from '../shared/seed'
const features = tableFeatures({ rowSortingFeature })
const rows = makePeople(50)
let density = $state<'compact' | 'normal' | 'comfortable'>('normal')
let theme = $state<'auto' | 'light' | 'dark' | 'high-contrast'>('auto')
// Complete palettes per theme. The previous version only overrode --sg-bg
// and --sg-fg, so the zebra rows / headers / borders kept the surrounding
// page's dark values and the grid ended up as light-and-dark stripes.
// A theme is either *every* relevant token or none (auto = inherit page).
type Palette = Record<string, string>
const THEME_PALETTES: Record<'light' | 'dark' | 'high-contrast', Palette> = {
light: {
'--sg-bg': '#ffffff',
'--sg-fg': '#0f172a',
'--sg-muted': '#64748b',
'--sg-border': '#e2e8f0',
'--sg-header-bg': '#f1f5f9',
'--sg-header-fg': '#0f172a',
'--sg-row-alt-bg': '#f8fafc',
'--sg-row-hover-bg': '#eef2ff',
'--sg-selection-bg': '#dbeafe',
'--sg-input-bg': '#ffffff',
'--sg-input-border': '#cbd5e1',
},
dark: {
'--sg-bg': '#0f172a',
'--sg-fg': '#f1f5f9',
'--sg-muted': '#94a3b8',
'--sg-border': '#334155',
'--sg-header-bg': '#1e2433',
'--sg-header-fg': '#f1f5f9',
'--sg-row-alt-bg': '#1b2230',
'--sg-row-hover-bg': '#232b3c',
'--sg-selection-bg': '#1d3a73',
'--sg-input-bg': '#1a2130',
'--sg-input-border': '#2c3548',
},
'high-contrast': {
'--sg-bg': '#000000',
'--sg-fg': '#ffffff',
'--sg-muted': '#d1d5db',
'--sg-border': '#ffffff',
'--sg-header-bg': '#000000',
'--sg-header-fg': '#ffffff',
'--sg-row-alt-bg': '#111111',
'--sg-row-hover-bg': '#1f2937',
'--sg-selection-bg': '#1e40af',
'--sg-input-bg': '#000000',
'--sg-input-border': '#ffffff',
},
}
const themeStyle = $derived(
theme === 'auto'
? ''
: Object.entries(THEME_PALETTES[theme])
.map(([k, v]) => `${k}:${v}`)
.join(';'),
)
// Snippets defined below are hoisted, so they are usable here.
function buildColumns(): GridColumns<Person> {
return [
{
id: 'person',
header: 'Person',
fieldFn: (row) => `${row.firstName} ${row.lastName}`,
cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),
},
{ field: 'department', header: 'Department' },
{ field: 'country', header: 'Country', width: 80 },
{
field: 'status',
header: 'Status',
cell: (ctx) => renderSnippet(StatusPill, { value: String(ctx.getValue()) }),
},
{
field: 'performance',
header: 'Performance',
editorType: 'number',
cell: (ctx) => renderSnippet(PerformanceBar, { value: Number(ctx.getValue()) }),
},
{
id: 'trend',
header: 'Trend (12mo)',
cell: (ctx) => renderSnippet(Sparkline, { row: ctx.row.original }),
},
{
field: 'salary',
header: 'Salary',
editorType: 'number',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },
},
]
}
const columns = buildColumns()
</script>
{#snippet PersonCell(props: { row: Person })}
{@const initials = props.row.firstName.charAt(0) + props.row.lastName.charAt(0)}
<span class="inline-flex items-center gap-2">
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-700 text-xs font-semibold dark:bg-blue-900 dark:text-blue-200">
{initials}
</span>
<span>{props.row.firstName} {props.row.lastName}</span>
</span>
{/snippet}
{#snippet StatusPill(props: { value: string })}
<span class="pill pill-{props.value}">{props.value}</span>
{/snippet}
{#snippet PerformanceBar(props: { value: number })}
<div
role="progressbar"
aria-label="performance"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={props.value}
class="inline-flex items-center gap-2"
>
<div class="perf-track relative h-1.5 w-24 rounded">
<div class="perf-fill absolute inset-y-0 left-0 rounded" style="width: {props.value}%"></div>
</div>
<span class="text-xs tabular-nums w-7 text-right">{props.value}</span>
</div>
{/snippet}
{#snippet Sparkline(props: { row: Person })}
{@const seed = props.row.id.length + props.row.age}
{@const bars = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => 4 + ((seed * (i + 3)) % 14))}
<span class="sparkbar" aria-label="trend">
{#each bars as h, i (i)}<span style="height: {h}px"></span>{/each}
</span>
{/snippet}
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="flex flex-wrap items-center gap-3 text-sm shrink-0">
<label class="flex items-center gap-2">
Density:
<select bind:value={density} class="sel rounded px-2 py-1">
<option value="compact">Compact</option>
<option value="normal">Normal</option>
<option value="comfortable">Comfortable</option>
</select>
</label>
<label class="flex items-center gap-2">
Theme:
<select bind:value={theme} class="sel rounded px-2 py-1">
<option value="auto">Auto (system)</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="high-contrast">High contrast</option>
</select>
</label>
<span class="hint">All controls are driven by CSS custom properties.</span>
</div>
<div
class="density-{density} flex flex-col flex-1 min-h-0"
data-theme={theme}
style={`--sg-row-height: ${density === 'compact' ? '28px' : density === 'comfortable' ? '48px' : '36px'}; ${themeStyle}`}
>
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
filterMode="none"
selectionMode="cell"
enableInlineEditing={false}
enableCellSelection={true}
rowHeight={density === 'compact' ? 28 : density === 'comfortable' ? 48 : 36}
containerHeight="100%"
/>
</div>
</section>
<style>
/* Page chrome only. The THEME_PALETTES block above is the demo's subject
and deliberately keeps its own literal token values. */
.sel {
border: 1px solid var(--sg-input-border, #cbd5e1);
background: transparent;
color: var(--sg-fg, #0f172a);
}
.hint { color: var(--sg-muted, #64748b); }
.perf-track { background: var(--sg-border, #e2e8f0); }
.perf-fill { background: var(--sg-accent, #2563eb); }
</style>More Rows & Cells examples
- Managed row dragging (grid-to-grid) - Reorder rows by dragging their grip, or move a row from one grid into another - both grids share a rowDragGroup, so the row leaves the source and lands in the target. The grid mutates its own data on drop and fires onRowDragEnd on the receiver.
- External drop zones (row drag) - Drag a row out of the grid onto any element - an Archive or Delete bucket - via the rowDropZone action. The row leaves the grid and the zone's onDrop handles it. In-grid reorder still works.
- Sparkline cells - In-cell mini charts as a first-class column type: set `sparkline` on a number-array column and the grid paints an inline SVG. Line, area, bar (with +/- coloring), and win/loss - no chart library, no custom snippet.
- Conditional formatting (engine) - Excel-style value-driven cell coloring as a declarative `conditionalFormats` engine prop: gradient heat maps (alpha ramp, zero-centred, banded, column-comparison), in-cell data bars, icon sets, and predicate rules - scoped per column, no per-cell snippet.
- Conditional styling - Support-ticket triage board: rowClass highlights SLA breach + at-risk rows with side-bar accents; cellClass paints priority pills, status badges, agent-load progress bars, and CSAT highlights.