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.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.
About this example
Configure the axes of a Svelte 5 chart: a numeric x axis that spreads payload sizes the way the numbers do, a pinned domain 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 and caption, a legend on any side and a tooltip snippet in single series mode. Below it, 50,000 monitoring readings decimated to about one point per pixel with LTTB or min / max, with a toggle that shows what the same chart costs without it. Free, in @svgrid/grid.
The chart used to take a categories: string[] list and decide the rest. This demo is the set of decisions it now hands back:
xAxis: { type: 'number' }positions marks by value, so payload sizes of 1, 2, 5, 10, 20, 50 and 100 KB spread out the way the numbers do. Bars on a numeric axis take their width from the smallest gap between two values, which is 1 KB here, so this chart draws an area and lines instead.yAxis/y2Axispin the domain, set the tick interval, format each axis on its own, and turn grid lines on and off.referenceBandsshades the SLA window; anaxis: 'x'reference line marks the payload size the CDN starts compressing at.- Per-series
marker,markers,step,dashandgradient. title/subtitle/caption, a legend on the right, and a tooltip snippet in'single'mode.
The second chart is 50,000 points of monitoring data. decimate thins it to about one point per pixel before layout, keeping the spikes; the toggle shows what the same chart costs without it.
Free, in @svgrid/grid.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature
Columns: kb (Payload (KB)), p50 (p50 ms), p95 (p95 ms), errors (Errors %), compressed (Compressed)
Frequently asked questions
How do I pin the value axis to a range?
Set yAxis.min and yAxis.max on the spec. Add nice: false when the ends must be exactly those numbers and tickInterval when the ticks should land on a fixed step. The right axis takes the same fields under y2Axis.
Why does the chart draw an area instead of bars on the numeric axis?
Bars on a numeric x axis take their width from the smallest gap between two values, which is 1 KB here against a 100 KB range, so every bar would be a sliver. Lines and areas read the spacing correctly, which is what this demo uses.
What does decimation drop?
Points, never spikes. LTTB keeps the point in each pixel bucket that preserves the line's shape, and min / max keeps the extremes of every bucket. Zoom is applied first, so zooming into 300 of the 50,000 points shows all 300.
Related documentation
Related articles
- 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.
Source code (434-chart-axes-styling.svelte)
<!-- Documented in: docs/help/charts/axes-and-styling.md -->
<script lang="ts">
/**
* 434. Axes, titles and styling
* -----------------------------
* The chart used to take a `categories: string[]` list and decide the rest.
* This demo is the set of decisions it now hands back:
*
* - `xAxis: { type: 'number' }` positions marks by value, so payload sizes of
* 1, 2, 5, 10, 20, 50 and 100 KB spread out the way the numbers do. Bars on
* a numeric axis take their width from the smallest gap between two values,
* which is 1 KB here, so this chart draws an area and lines instead.
* - `yAxis` / `y2Axis` pin the domain, set the tick interval, format each
* axis on its own, and turn grid lines on and off.
* - `referenceBands` shades the SLA window; an `axis: 'x'` reference line
* marks the payload size the CDN starts compressing at.
* - Per-series `marker`, `markers`, `step`, `dash` and `gradient`.
* - `title` / `subtitle` / `caption`, a legend on the right, and a tooltip
* snippet in `'single'` mode.
*
* The second chart is 50,000 points of monitoring data. `decimate` thins it
* to about one point per pixel before layout, keeping the spikes; the toggle
* shows what the same chart costs without it.
*
* Free, in @svgrid/grid.
*/
import { SvChart, SvGrid, tableFeatures, rowSortingFeature, type ChartSpec, type GridColumns } from '@svgrid/grid'
const features = tableFeatures({ rowSortingFeature })
type Row = { kb: number; p50: number; p95: number; errors: number; compressed: boolean }
const rows: Row[] = [
{ kb: 1, p50: 8, p95: 12, errors: 0.2, compressed: false },
{ kb: 2, p50: 9, p95: 14, errors: 0.2, compressed: false },
{ kb: 5, p50: 12, p95: 19, errors: 0.3, compressed: false },
{ kb: 10, p50: 16, p95: 27, errors: 0.4, compressed: false },
{ kb: 20, p50: 24, p95: 45, errors: 0.6, compressed: true },
{ kb: 50, p50: 51, p95: 98, errors: 1.4, compressed: true },
{ kb: 100, p50: 96, p95: 190, errors: 2.9, compressed: true },
]
const columns: GridColumns<Row> = [
{ field: 'kb', header: 'Payload (KB)', width: 120, align: 'right' },
{ field: 'p50', header: 'p50 ms', width: 90, align: 'right' },
{ field: 'p95', header: 'p95 ms', width: 90, align: 'right' },
{ field: 'errors', header: 'Errors %', width: 90, align: 'right', cell: (c) => `${c.getValue()}%` },
{ field: 'compressed', header: 'Compressed', width: 100, cell: (c) => (c.getValue() ? 'yes' : 'no') },
]
let legendPos = $state<'right' | 'bottom' | 'top' | 'left'>('right')
let rotate = $state(false)
let logX = $state(false)
const latency = $derived<ChartSpec>({
type: 'area',
title: 'Latency by payload size',
subtitle: 'p50 as an area, p95 as a line, error rate on the right axis',
caption: 'Synthetic data; the SLA band is the p95 budget for the API tier.',
categories: rows.map((r) => String(r.kb)),
series: [
// Per-point colours: the compressed payloads get their own marker colour.
{ label: 'p50', values: rows.map((r) => r.p50), gradient: true, colors: rows.map((r) => (r.compressed ? '#0ea5e9' : null)) },
{ label: 'p95', values: rows.map((r) => r.p95), type: 'line', marker: 'diamond', strokeWidth: 2.5,
markers: rows.map((r) => (r.p95 > 150 ? { shape: 'triangle', size: 5, color: '#ef4444' } : null)) },
{ label: 'Errors', values: rows.map((r) => r.errors), type: 'line', axis: 'right', step: 'after', dash: [5, 3], marker: 'square' },
],
// A log x axis spreads the small payloads out: 1, 2, 5, 10 stop piling up
// at the left edge while 100 KB is still on the chart.
xAxis: { type: 'number', scale: logX ? 'log' : 'linear', title: 'Payload (KB)', gridLines: true, labelRotation: rotate ? -45 : 0, formatter: (v) => `${v} KB` },
yAxis: { min: 0, max: 200, tickInterval: 50, title: 'ms', formatter: (v) => `${v} ms` },
y2Axis: { min: 0, max: 4, tickInterval: 1, title: 'Error rate', formatter: (v) => `${v}%`, gridLines: false },
referenceBands: [{ from: 0, to: 120, color: '#16a34a', opacity: 0.07, label: 'p95 budget' }],
referenceLines: [{ axis: 'x', value: 20, label: 'compression on', color: '#8b5cf6' }],
height: 320,
})
// ---- 50,000 points ---------------------------------------------------
let decimate = $state(true)
let method = $state<'lttb' | 'minmax'>('lttb')
const N = 50_000
const monitoring = (() => {
let seed = 7
const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
const categories: string[] = new Array(N)
const cpu: number[] = new Array(N)
const start = Date.UTC(2026, 0, 1)
let level = 40
for (let i = 0; i < N; i += 1) {
categories[i] = new Date(start + i * 60_000).toISOString()
level += (rnd() - 0.5) * 2 + Math.sin(i / 700) * 0.4
level = Math.max(5, Math.min(95, level))
// Occasional spikes: the thing a monitoring chart exists to show.
cpu[i] = Math.round((rnd() < 0.0008 ? 98 : level) * 10) / 10
}
return { categories, cpu }
})()
const dense = $derived<ChartSpec>({
type: 'line',
title: `CPU, one reading a minute for ${(N / 1440).toFixed(0)} days`,
subtitle: decimate ? `${method === 'lttb' ? 'Largest-Triangle-Three-Buckets' : 'min / max per bucket'}, about one point per pixel` : 'every one of the 50,000 points laid out',
categories: monitoring.categories,
series: [{ label: 'CPU %', values: monitoring.cpu, marker: 'none', strokeWidth: 1.25 }],
xType: 'time',
yAxis: { min: 0, max: 100, tickInterval: 25, formatter: (v) => `${v}%` },
referenceLines: [{ value: 90, label: 'alert', color: '#ef4444' }],
decimate: decimate ? { method } : false,
height: 220,
})
let laidOut = $state(0)
let renderMs = $state(0)
$effect(() => {
// Read the two toggles so this re-runs when they change, then time the
// next paint. Layout happens inside the component; this brackets it.
void decimate
void method
const t0 = performance.now()
const raf = requestAnimationFrame(() => {
renderMs = Math.round(performance.now() - t0)
laidOut = document.querySelectorAll('.demo-dense .sv-grid-chart-linepath').length ? countPoints() : 0
})
return () => cancelAnimationFrame(raf)
})
function countPoints(): number {
const d = document.querySelector('.demo-dense .sv-grid-chart-linepath')?.getAttribute('d') ?? ''
return d.split(/[ML]/).length - 1
}
</script>
<section class="wrap">
<header class="chrome">
<label class="ctl">
Legend
<select bind:value={legendPos}>
<option value="right">right</option>
<option value="left">left</option>
<option value="top">top</option>
<option value="bottom">bottom</option>
</select>
</label>
<label class="chk"><input type="checkbox" bind:checked={rotate} /> Rotate x labels</label>
<label class="chk"><input type="checkbox" bind:checked={logX} /> Log x</label>
<span class="note">
Numeric x axis, pinned domains with fixed tick intervals, a shaded budget band, a vertical
reference line, per-point markers and colours, a stepped dashed series on the right axis,
and a single-series tooltip.
</span>
</header>
<div class="body">
<div class="pane pane-main">
<SvChart spec={latency} legend={legendPos} tooltipMode="single" dataLabels={{ placement: 'top', hideOverlap: true }}>
{#snippet tooltip({ category, series, value, rows: tipRows })}
<div class="tip">
<b>{series ?? 'All series'}</b> at {category} KB
{#if value != null}
<div class="tip-val">{series === 'Errors' ? `${value}%` : `${value} ms`}</div>
{:else}
{#each tipRows as r (r.label)}<div>{r.label}: {r.value}</div>{/each}
{/if}
</div>
{/snippet}
</SvChart>
</div>
<div class="grid-host">
<SvGrid data={rows} {columns} {features} sortable rowHeight={26} containerHeight="100%" fitColumns responsive />
</div>
</div>
<div class="pane demo-dense">
<div class="dense-bar">
<label class="chk"><input type="checkbox" bind:checked={decimate} /> Decimate</label>
<label class="ctl">
Method
<select bind:value={method} disabled={!decimate}>
<option value="lttb">LTTB (keeps the shape)</option>
<option value="minmax">min / max (keeps every spike)</option>
</select>
</label>
<span class="note">{laidOut.toLocaleString()} points in the path, painted in {renderMs} ms.</span>
</div>
<SvChart spec={dense} legend={false} zoomable brush autosize />
</div>
</section>
<style>
.wrap {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 10px;
overflow: auto;
}
.chrome {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex: none;
}
.note {
font-size: 12px;
color: var(--sg-muted, #64748b);
}
.chk,
.ctl {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--sg-fg, #0f172a);
}
.ctl select {
font: inherit;
border: 1px solid var(--sg-border, #e2e8f0);
border-radius: 6px;
background: var(--sg-bg, #fff);
color: inherit;
padding: 2px 6px;
}
.body {
display: flex;
flex: none;
gap: 12px;
min-height: 0;
}
.pane {
border: 1px solid var(--sg-border, #e2e8f0);
border-radius: 10px;
background: var(--sg-bg, #fff);
padding: 8px 10px;
min-width: 0;
}
.pane-main {
flex: 1 1 60%;
}
.grid-host {
flex: 1 1 40%;
min-width: 0;
min-height: 200px;
}
.demo-dense {
flex: none;
}
.dense-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.tip {
background: var(--sg-bg, #fff);
color: var(--sg-fg, #0f172a);
border: 1px solid var(--sg-border, #e2e8f0);
border-radius: 6px;
padding: 6px 9px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
font-size: 12px;
}
.tip-val {
font-size: 14px;
font-weight: 600;
}
@media (max-width: 720px) {
.body {
flex-direction: column;
}
}
</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.
- Every chart type - Every chart type from one dataset, thirty live thumbnails: bar, horizontal bar, line, area, lollipop, dumbbell, range bar, range area, pareto, radial column, radial bar, nightingale, pie / donut, tree map, sunburst, funnel, waterfall, sankey, chord, radar, heat map, scatter, box plot, histogram, gauge, bullet, calendar, stream, candlestick and OHLC. Click a card for the full-size chart and its variants: classic / hollow / Heikin-Ashi candles, funnel / pyramid / cone, wiggle / silhouette streams, stacking. Every spec is built with the helpers the grid chart panel uses.