Integrated charts (no deps)
Chart the grid data with no external charting library. SvGridChart renders a ChartSpec; rowsToChartSpec aggregates the grid current (filtered/sorted) rows into one. Bar, line, area, pie - plus 100% stacked, top-N + Other, an average reference line, and double-click-to-isolate a series. Filter the grid and the chart re-aggregates live.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.
About this example
Charting the Svelte 5 data grid with no external charting library. SvGridChart renders a ChartSpec and rowsToChartSpec aggregates the grid's current filtered and sorted rows into one: bar, line, area, pie and donut, grouped or stacked, 100 percent stacked, top-N plus Other, a combo of bar and line on a second axis, a signed Y axis for negatives, an average reference line, tooltips, a clickable legend with double-click to isolate a series, data labels, click-to-drill through onSelect and SVG or PNG export. Filter the grid and the chart re-aggregates live.
Chart the grid's data with no external charting library. SvGridChart renders a ChartSpec; rowsToChartSpec aggregates the grid's CURRENT (filtered/sorted) rows. Bar / line / area / pie + donut, grouped or stacked, combo (bar + line on a 2nd axis), signed Y axis (negatives), tooltips, clickable legend, data labels, click-to-drill, and SVG/PNG export.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: rep (Rep), region (Region), product (Product), revenue (Revenue), deals (Deals), growth (Growth %)
SvGridApi methods called: api.clearAllFilters(), api.getDisplayedRows(), api.selectCells(), api.setFacetFilter()
Frequently asked questions
How does the chart follow the grid?
onFiltersChange and onSortingChange call a sync that reads api.getDisplayedRows() and rebuilds the spec with rowsToChartSpec; the chart re-renders from the new spec.
How does click-to-drill work?
onSelect receives the clicked category and series; the demo applies api.setFacetFilter for that value so the grid narrows to the rows behind the bar, and api.clearAllFilters resets.
Which chart types are available?
bar, line, area, pie and donut here, plus scatter, heatmap, waterfall, funnel, radar, calendar, gauge, treemap, sankey, candlestick and box plot in the other chart demos, all from the same SvGridChart.
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.
Source code (147-integrated-charts.svelte)
<!-- Documented in: docs/help/charts.md -->
<script lang="ts">
/**
* 147. Integrated charts
* ----------------------
* Chart the grid's data with no external charting library. `SvGridChart`
* renders a `ChartSpec`; `rowsToChartSpec` aggregates the grid's CURRENT
* (filtered/sorted) rows. Bar / line / area / pie + donut, grouped or
* stacked, combo (bar + line on a 2nd axis), signed Y axis (negatives),
* tooltips, clickable legend, data labels, click-to-drill, and SVG/PNG
* export.
*/
import {
SvGrid,
SvGridChart,
rowsToChartSpec,
downloadChartSvg,
downloadChartPng,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type GridColumns,
type SvGridApi,
type ChartType,
type ChartSpec,
type ChartSelection,
} from '@svgrid/grid'
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
type Row = { id: number; rep: string; region: string; product: string; revenue: number; deals: number; growth: number }
const REGIONS = ['Americas', 'EMEA', 'APAC']
const PRODUCTS = ['PLC', 'Drivers', 'Rivets', 'Stock']
const NAMES = ['Ada', 'Grace', 'Alan', 'Margaret', 'Linus', 'Donald', 'Brian', 'Dennis', 'Barbara', 'Ken']
let seed = 0xc0ffee
const rnd = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff)
// Per-product growth trend so some categories aggregate NEGATIVE (group by
// Product + Growth % to see the signed axis).
const TREND: Record<string, number> = { PLC: 16, Drivers: -9, Rivets: 5, Stock: -13 }
const rows: Row[] = Array.from({ length: 60 }, (_, id) => {
const product = PRODUCTS[id % 4]!
return {
id,
rep: NAMES[id % NAMES.length]!,
region: REGIONS[id % 3]!,
product,
revenue: Math.round(10_000 + rnd() * 190_000),
deals: Math.round(2 + rnd() * 40),
growth: Math.round(TREND[product]! + (rnd() - 0.5) * 14),
}
})
const columns: GridColumns<Row> = [
{ field: 'rep', header: 'Rep', width: 110 },
{ field: 'region', header: 'Region', width: 110 },
{ field: 'product', header: 'Product', width: 110 },
{ field: 'revenue', header: 'Revenue', width: 140, align: 'right', format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
{ field: 'deals', header: 'Deals', width: 90, align: 'right' },
{ field: 'growth', header: 'Growth %', width: 100, align: 'right' },
]
let api = $state<SvGridApi<typeof features, Row> | null>(null)
let displayed = $state<Row[]>(rows)
let chartType = $state<ChartType>('bar')
let category = $state<'region' | 'product' | 'rep'>('region')
let measure = $state<'revenue' | 'deals' | 'growth' | 'both'>('revenue')
// Revenue (millions) and Deals (dozens) live on wildly different scales, so
// "Revenue + Deals" defaults to combo: Deals becomes a line on a secondary
// right axis. Uncheck Combo to compare them on one (shared) axis.
let stacked = $state(false)
let stacked100 = $state(false)
let combo = $state(true)
let dataLabels = $state(false)
let donut = $state(false)
let topN = $state(false)
let avgLine = $state(false)
let horizontal = $state(false)
let drill = $state<string | null>(null)
let chartWrap = $state<HTMLElement | null>(null)
// Rows covered by the current cell-selection range(s). When non-empty the
// chart aggregates ONLY these - the "select a range, chart it" loop.
let selectedRows = $state<Row[]>([])
function sync() {
displayed = (api?.getDisplayedRows() as Row[]) ?? rows
}
// Map selection rectangles ([rowStart, _, rowEnd, _]) to the displayed rows.
function onSelectionChange(ranges: Array<[number, number, number, number]>) {
const idx = new Set<number>()
for (const [r0, , r1] of ranges) {
const lo = Math.min(r0, r1)
const hi = Math.max(r0, r1)
for (let r = lo; r <= hi; r++) idx.add(r)
}
selectedRows = [...idx].map((i) => displayed[i]).filter((r): r is Row => !!r)
}
// Aggregate the selection when there is one, otherwise the full displayed set.
const chartRows = $derived(selectedRows.length ? selectedRows : displayed)
// Compact + currency-aware so the Y-axis ticks and tooltips match (e.g.
// "$2M" on the axis, "$2.1M" in the tooltip - never "2000000").
const compactNum = (v: number) => {
const a = Math.abs(v)
if (a >= 1e6) return (v / 1e6).toFixed(a % 1e6 ? 1 : 0) + 'M'
if (a >= 1e3) return (v / 1e3).toFixed(a % 1e3 ? 1 : 0) + 'k'
return String(Math.round(v))
}
const fmtVal = (v: number) => (Math.abs(v) >= 1000 ? '$' + compactNum(v) : compactNum(v))
const cap = (s: string) => s[0]!.toUpperCase() + s.slice(1)
const measureTitle = $derived(measure === 'deals' ? 'Deals' : measure === 'growth' ? 'Growth %' : 'Revenue')
const spec = $derived.by<ChartSpec>(() => {
const measures = measure === 'both' ? (['revenue', 'deals'] as const) : [measure]
const s = rowsToChartSpec(chartRows, {
type: chartType,
category,
value: measures as any,
reduce: 'sum',
stacked: stacked && chartType !== 'pie',
stacked100: stacked100 && stacked && chartType !== 'pie',
// Rank categories and bucket the long tail into "Other" (great for `by Rep`).
sort: topN ? 'value-desc' : 'none',
topN: topN ? 5 : undefined,
width: 520,
height: 300,
})
// Horizontal bars suit long category labels (e.g. by Rep). Only bars.
const isHoriz = horizontal && chartType === 'bar' && measure !== 'both'
if (isHoriz) {
s.orientation = 'horizontal'
s.yAxisTitle = cap(category)
s.xAxisTitle = stacked100 && stacked ? 'Share' : measureTitle
} else {
s.yAxisTitle = stacked100 && stacked ? 'Share' : measureTitle
s.xAxisTitle = cap(category)
}
// Combo: draw the 2nd measure as a line on the secondary (right) axis.
if (combo && !stacked && measure === 'both' && chartType !== 'pie' && s.series[1]) {
s.series[1] = { ...s.series[1], type: 'line', axis: 'right' }
s.y2AxisTitle = 'Deals'
}
// Average reference line across the categories (single-measure, not 100%).
if (avgLine && chartType !== 'pie' && !(stacked100 && stacked) && s.series[0]) {
const vals = s.series[0].values.filter((v) => Number.isFinite(v))
const avg = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0
s.referenceLines = [{ value: Math.round(avg), label: `Avg ${fmtVal(avg)}` }]
}
if (chartType === 'pie') s.innerRadius = donut ? 0.62 : 0
return s
})
function onChartSelect(sel: ChartSelection) {
drill = sel.category
api?.setFacetFilter(category, [sel.category])
}
function resetDrill() {
drill = null
api?.clearAllFilters()
}
/** 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);">
Live chart bound to the grid via <code>rowsToChartSpec</code> + <code>SvGridChart</code>
</p>
<p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
Hover for a unified tooltip + crosshair · click a legend chip to toggle, double-click to isolate · click a bar to drill · <strong>drag a cell range to chart just that selection</strong>.
</p>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
<select bind:value={chartType} class="ic-sel">
<option value="bar">Bar</option>
<option value="line">Line</option>
<option value="area">Area</option>
<option value="pie">Pie</option>
</select>
<select bind:value={category} class="ic-sel">
<option value="region">by Region</option>
<option value="product">by Product</option>
<option value="rep">by Rep</option>
</select>
<select bind:value={measure} class="ic-sel">
<option value="revenue">Revenue</option>
<option value="deals">Deals</option>
<option value="growth">Growth % (signed)</option>
<option value="both">Revenue + Deals</option>
</select>
{#if chartType !== 'pie'}
<label class="ic-chk"><input type="checkbox" bind:checked={stacked} disabled={measure !== 'both'} onchange={() => stacked && (combo = false)} /> Stacked</label>
<label class="ic-chk"><input type="checkbox" bind:checked={stacked100} disabled={!stacked || measure !== 'both'} /> 100%</label>
<label class="ic-chk"><input type="checkbox" bind:checked={combo} disabled={measure !== 'both'} onchange={() => combo && (stacked = false)} /> Combo (2nd axis)</label>
<label class="ic-chk"><input type="checkbox" bind:checked={avgLine} /> Avg line</label>
{#if chartType === 'bar'}
<label class="ic-chk"><input type="checkbox" bind:checked={horizontal} disabled={measure === 'both'} /> Horizontal</label>
{/if}
{:else}
<label class="ic-chk"><input type="checkbox" bind:checked={donut} /> Donut</label>
{/if}
<label class="ic-chk"><input type="checkbox" bind:checked={topN} /> Top 5 + Other</label>
<label class="ic-chk"><input type="checkbox" bind:checked={dataLabels} /> Data labels</label>
<span class="mx-1 h-4 w-px" style="background: var(--sg-border)"></span>
<button type="button" class="ic-btn" onclick={() => chartWrap && downloadChartSvg(chartWrap, 'chart.svg')}>SVG</button>
<button type="button" class="ic-btn" onclick={() => chartWrap && downloadChartPng(chartWrap, 'chart.png')}>PNG</button>
{#if selectedRows.length}
<span class="ic-badge" style="background: var(--sg-accent, #2563eb)">Charting {selectedRows.length} selected row{selectedRows.length === 1 ? '' : 's'}</span>
<button type="button" class="ic-clear" onclick={() => { selectedRows = []; api?.selectCells([]) }}>clear</button>
{/if}
{#if drill}
<span class="ic-badge">Drilled: {drill}</span>
<button type="button" class="ic-clear" onclick={resetDrill}>reset</button>
{/if}
</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="cell"
rowHeight={32}
containerHeight="100%"
fitColumns={true}
onApiReady={(a) => { api = a; sync() }}
onFiltersChange={() => { sync(); selectedRows = [] }}
onSortingChange={() => { sync(); selectedRows = [] }}
onCellSelectionChange={onSelectionChange}
/>
</div>
<div class="rounded-lg border p-3" style="flex: 0 1 560px; min-width: 0; min-height: 0; border-color: var(--sg-border); background: var(--sg-bg);" bind:this={chartWrap}>
<!-- 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} formatValue={fmtVal} onSelect={onChartSelect} 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 { accent-color: var(--sg-accent); }
.ic-chk:has(input:disabled) { opacity: 0.45; }
.ic-btn {
border: 1px solid var(--sg-border);
background: var(--sg-bg);
color: var(--sg-fg);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.ic-btn:hover { border-color: var(--sg-accent); }
.ic-badge {
font-size: 11px;
font-weight: 700;
color: var(--sg-on-accent, #fff);
background: var(--sg-accent, #2563eb);
border-radius: 999px;
padding: 2px 9px;
}
.ic-clear {
font-size: 11px;
color: var(--sg-muted);
background: transparent;
border: 0;
text-decoration: underline;
cursor: pointer;
}
</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.