Heatmap chart
type: heatmap renders a colored grid (one cell per row/column) from the same categories + series shape. Choose a sequential or diverging color ramp; cell text auto-contrasts black/white. Filter the grid Channel column and the heatmap re-renders.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.
About this example
A heatmap chart in Svelte 5. type heatmap renders a coloured grid with one cell per row and column from the same categories and series shape as other charts, where each series is a row and its values are the cells. colorScale picks a sequential ramp by default, a diverging red to blue ramp for data that straddles zero or a custom hex array, and cell text auto-contrasts black or white. The demo builds an hour by weekday traffic heatmap from the grid's displayed rows; filter the Channel column and it re-renders.
type: 'heatmap' renders a coloured grid - one cell per (row, column). Reuse the existing categories (column headers) + series (one series per row, values is the row's cells) shape. The colorScale option picks a sequential ramp (default) or a diverging red->blue ramp for data that straddles 0. A custom hex-array works too. Cell text colour is auto-chosen for WCAG contrast against the cell.
This demo: a "hour x weekday" traffic heatmap built from the grid's displayed rows. Filter the channel column and the heatmap re-renders.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: day (Day), hour (Hour), channel (Channel), visits (Visits)
SvGridApi methods called: api.getDisplayedRows()
Frequently asked questions
How is the heatmap data shaped?
categories are the column headers, hours here, and each series is one row, a weekday, with values holding the cell numbers; the same shape as a stacked bar spec, so rowsToChartSpec can produce it.
When should I use the diverging ramp?
When values run from negative to positive around a meaningful zero, such as change versus last week. The ramp centres on zero so the sign is readable at a glance.
Are the cell labels always readable?
Yes. dataLabels prints each value and the chart picks black or white text per cell for WCAG contrast against the cell colour.
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.
- Conditional Formatting - Color Cells by Their Value - Four rule types, one prop - add heatmaps, data bars, icon sets, and threshold highlights to any SvGrid column without custom cell renderers.
Source code (154-chart-heatmap.svelte)
<!-- Documented in: docs/help/charts/types.md -->
<script lang="ts">
/**
* 154. Heatmap chart
* ------------------
* `type: 'heatmap'` renders a coloured grid - one cell per (row, column).
* Reuse the existing `categories` (column headers) + `series` (one series
* per row, `values` is the row's cells) shape. The `colorScale` option
* picks a sequential ramp (default) or a diverging red->blue ramp for
* data that straddles 0. A custom hex-array works too. Cell text colour
* is auto-chosen for WCAG contrast against the cell.
*
* This demo: a "hour x weekday" traffic heatmap built from the grid's
* displayed rows. Filter the channel column and the heatmap re-renders.
*/
import {
SvGrid,
SvGridChart,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type GridColumns,
type SvGridApi,
type ChartSpec,
} from '@svgrid/grid'
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
type Row = { id: number; day: string; hour: number; channel: string; visits: number }
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const CHANNELS = ['Organic', 'Paid', 'Social']
let seed = 0xc0ffee
const rnd = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff)
// Synthesize a realistic shape: workday peaks 9-11 + 14-17; weekends shifted later.
function shape(d: string, h: number, ch: string): number {
const weekend = d === 'Sat' || d === 'Sun'
const peak = weekend ? Math.max(0, 1 - Math.abs(h - 14) / 6) : Math.max(0, 1 - Math.abs(h - 13) / 5)
const base = ch === 'Paid' ? 80 : ch === 'Social' ? 60 : 120
return Math.round(base * (0.2 + peak * 1.6) * (0.7 + rnd() * 0.6))
}
let nid = 0
const rows: Row[] = DAYS.flatMap((day) =>
Array.from({ length: 24 }, (_, hour) =>
CHANNELS.map((channel) => ({
id: nid++,
day,
hour,
channel,
visits: shape(day, hour, channel),
})),
).flat(),
)
const columns: GridColumns<Row> = [
{ field: 'day', header: 'Day', width: 90 },
{ field: 'hour', header: 'Hour', width: 80, align: 'right' },
{ field: 'channel', header: 'Channel', width: 110 },
{ field: 'visits', header: 'Visits', width: 90, align: 'right' },
]
let api = $state<SvGridApi<typeof features, Row> | null>(null)
let displayed = $state<Row[]>(rows)
let scale = $state<'sequential' | 'diverging'>('sequential')
let showLabels = $state(false)
function sync() { displayed = (api?.getDisplayedRows() as Row[]) ?? rows }
// Aggregate filtered rows into a (day x hour) matrix.
const spec = $derived.by<ChartSpec>(() => {
const buckets = new Map<string, Map<number, number>>()
for (const r of displayed) {
let row = buckets.get(r.day)
if (!row) { row = new Map(); buckets.set(r.day, row) }
row.set(r.hour, (row.get(r.hour) ?? 0) + r.visits)
}
const series = DAYS.map((d) => {
const m = buckets.get(d)
return {
label: d,
values: Array.from({ length: 24 }, (_, h) => m?.get(h) ?? 0),
}
})
return {
type: 'heatmap',
categories: Array.from({ length: 24 }, (_, h) => String(h).padStart(2, '0')),
series,
colorScale: scale,
width: 760,
height: 320,
}
})
/** Pane size, so the chart fills its card rather than a fixed viewBox. */
let paneW = $state(0)
let paneH = $state(0)
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-border); background: var(--sg-header-bg);">
<p class="text-sm font-semibold" style="color: var(--sg-fg);">
Heatmap built from the grid's filtered rows
</p>
<p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
Filter the Channel column to isolate one source; the heatmap re-renders. Hover any cell for
the day/hour breakdown.
</p>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
<select bind:value={scale} class="ic-sel">
<option value="sequential">Sequential ramp</option>
<option value="diverging">Diverging ramp</option>
</select>
<label class="ic-chk"><input type="checkbox" bind:checked={showLabels} /> Cell values</label>
</div>
</div>
<div class="flex flex-1 min-h-0 gap-3">
<div class="flex-1 min-w-0 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
sortable
filterable
selectionMode="none"
rowHeight={28}
containerHeight="100%"
fitColumns={true}
onApiReady={(a) => { api = a; sync() }}
onFiltersChange={sync}
onSortingChange={sync}
/>
</div>
<div class="rounded-lg border p-3" style="flex: 0 1 800px; min-width: 0; min-height: 0; border-color: var(--sg-border); background: var(--sg-bg);">
<!-- Measured box, not the card: its height comes from the parent, so the
chart cannot push the thing it is sized against. -->
<div style="width: 100%; height: 100%; min-height: 0;" bind:clientWidth={paneW} bind:clientHeight={paneH}>
{#if paneW > 40 && paneH > 40}
<SvGridChart {spec} dataLabels={showLabels} width={paneW} height={paneH} />
{/if}
</div>
</div>
</div>
</section>
<style>
.ic-sel {
border: 1px solid var(--sg-input-border, var(--sg-border));
background: var(--sg-input-bg, var(--sg-bg));
color: var(--sg-fg);
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
}
.ic-chk { display: inline-flex; align-items: center; gap: 4px; color: var(--sg-fg); }
.ic-chk input[type='checkbox'] { accent-color: var(--sg-accent); }
</style>More Charts examples
- Chart a selection (context menu) - With integrated charting enabled, the right-click menu gains a Chart selected range item: select a block of cells, right-click, and the chart panel opens scoped to that range - the Excel chart-this gesture, built in. The item appears only when charting is on and is appended to the default context menu automatically.
- Chart view of the grid - The `chart` prop turns the same <SvGrid> into a chart, driven by the grid’s filtered + sorted rows (search + sort flow through). A view of the grid like board and scheduler, but the renderer is free: the grid lazy-loads a built-in view wrapping the standalone SvChart via rowsToChartSpec. Flip Table <-> Chart (bar / line / area) over one source of truth.
- Candlestick / OHLC - Candlestick and OHLC price marks with an ordinal date axis. ChartSeries.ohlc carries the four prices while values keeps the closes, so the CSV export, the screen-reader table and a 10-day moving average all work with no candle-specific code. Toggle the axis: a real time axis opens a gap over every weekend, an ordinal one spaces sessions evenly and still labels them by date. The strip under the plot is the chart brush: drag its window to pan, drag an edge to resize.
- Box plot + error bars - Distribution rather than average: box plots with the 1.5 IQR whisker rule and individual outliers, next to the same data as a bar chart of the means with error bars. two regions with nearly identical means sit side by side in the bars and look nothing alike in the boxes. boxStats() summarises a raw sample, rowsToBoxSpec() does it per group, and ChartSeries.errors annotates any existing mark without changing its type.
- Axes, titles and styling - The chart hands the decisions back: a numeric x axis that spreads payload sizes the way the numbers do, pinned domains with a fixed tick interval and a formatter per axis, grid lines on or off, a shaded budget band, a vertical reference line, per-point markers and colours, a stepped dashed series on the right axis, a title / subtitle / caption, a legend on any side, and a tooltip snippet in single-series mode. Below it, 50,000 monitoring readings decimated to one point per pixel with LTTB or min / max - toggle it off to see the cost.