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

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>

View this example on GitHub

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.