LineChart
Responsive SVG line chart with axes, gridlines, and a hover crosshair tooltip.
Live demo
01Two series β revenue vs costs
Hover the plot (or focus it and press the arrow keys) to move the crosshair and read values at each quarter.
- Revenue
- Costs
| Quarter | Revenue | Costs |
|---|---|---|
| Q1 | 12 | 9 |
| Q2 | 19 | 11 |
| Q3 | 17 | 12 |
| Q4 | 24 | 14 |
| Q5 | 28 | 16 |
| Q6 | 26 | 17 |
| Q7 | 33 | 19 |
| Q8 | 39 | 22 |
Single series β negatives & nice-scale
Temperature anomaly with negative values; the auto scale keeps a tidy zero-crossing baseline.
- Anomaly Β°C
| Year | Anomaly Β°C |
|---|---|
| 2020 | -0.3Β° |
| 2021 | 0.1Β° |
| 2022 | 0.6Β° |
| 2023 | 0.4Β° |
| 2024 | 1.1Β° |
| 2025 | 1.4Β° |
Three series β large numbers, no dots
Sessions by channel. The compact Y formatter renders thousands as k, and
dots are switched off for a cleaner trend read.
- Organic
- Paid
- Referral
| Month | Organic | Paid | Referral |
|---|---|---|---|
| 1 | 1.2k | 800 | 300 |
| 2 | 1.8k | 950 | 420 |
| 3 | 2.4k | 1.1k | 380 |
| 4 | 2.1k | 1.6k | 510 |
| 5 | 3.2k | 1.4k | 640 |
| 6 | 4.1k | 1.9k | 720 |
Implementation
02<script lang="ts">
import LineChart from '$lib/components/LineChart.svelte';
β
const series = [
{ name: 'Revenue', colour: '#4f7cff', points: [{ x: 1, y: 12 }, { x: 2, y: 19 }, { x: 3, y: 17 }, { x: 4, y: 24 }] }
];
</script>
β
<LineChart {series} xLabel="Quarter" yLabel="Β£m" />The chart derives a "nice" Y scale by rounding the data extent out to a tidy step (1/2/5 Γ 10βΏ), then maps each point to pixels inside a responsive viewBox whose width is tracked by a ResizeObserver. Hovering or arrowing snaps a crosshair to the nearest x-value and reads every series value into an HTML tooltip overlay; the same numbers are mirrored in a visually-hidden table so screen-reader users get the full dataset.
Logic explainer
03What Does It Do? (Plain English)
LineChart draws one or more lines over a shared X axis using nothing but inline
SVG. You hand it an array of series β each with a name, a colour, and a list of{ x, y } points β and it works out a tidy value scale, lays out gridlines and
tick labels, and renders a crisp, responsive chart that resizes with its
container.
Hovering (or arrowing) across the plot snaps a vertical crosshair to the nearest
X position and pops a tooltip listing every series' value at that point. For
screen-reader users the same numbers are mirrored in a visually-hidden data
table, so the chart is never a dead-end for assistive technology.
It is deliberately dependency-free: no D3, no charting library, no icon set β
just Svelte 5 runes, scoped CSS, and SVG geometry you can read and tweak.
How It Works (Pseudo-Code)
on mount:
observe container width with ResizeObserver β containerWidth
derive geometry:
width = max(containerWidth, 240)
innerW = width β marginLeft β marginRight
innerH = height β marginTop β marginBottom
derive xValues:
collect every point.x across all series β dedupe β sort ascending
(these are evenly-spaced categorical slots)
derive yScale (nice-scaled):
find data min / max across all series
span = niceNum(max β min)
step = niceNum(span / (yTicks β 1), round=true) // 1 / 2 / 5 Γ 10βΏ
niceMin = floor(min / step) Γ step
niceMax = ceil (max / step) Γ step
ticks = [niceMin, niceMin+step, β¦ , niceMax]
map to pixels:
xPos(x) = marginLeft + index(x)/(nβ1) Γ innerW
yPos(y) = marginTop + (1 β (yβmin)/(maxβmin)) Γ innerH // SVG y is inverted
render:
for each yTick β gridline + Y label
draw the two axis baselines + X labels
for each series β <path d="M β¦" /> + optional dots
if active β crosshair line + HTML tooltip overlay
always β legend + visually-hidden <table> // role="application", focusable surface
interaction:
pointermove β activeIndex = nearest xValue to cursor
ArrowLeft/Right β step activeIndex; Home/End β first/last; Escape β clearThe Core Concept: Nice-Scaled Axes
A naive chart maps the raw data min/max straight onto the plot edges, which
produces ugly axis labels like 13.7 or 1284. LineChart instead runs a
"nice number" algorithm (the same idea behind most plotting libraries): it
rounds the data range out to the nearest 1, 2, 5 or 10 times a power of
ten, then chooses a step that yields roughly yTicks gridlines. The result is
human-friendly labels (0, 5, 10, 15, 20) and a baseline that snaps to zero
when the data naturally includes it.
CSS Animation Strategy
There is very little motion by design β charts should feel solid, not jittery.
The only transition is the active data dot growing from r=3 to r=5, which is
suppressed under prefers-reduced-motion: reduce. Stroke widths usevector-effect: non-scaling-stroke so that lines, gridlines and the crosshair
stay exactly 1β2px crisp no matter how the viewBox is stretched by the
responsive container β the SVG scales geometry, not stroke pixels.
Performance
The chart re-derives geometry only when its inputs change ($derived), and the
ResizeObserver is the single source of width updates β there is no scroll or
resize listener storm. Rendering cost is O(points) for the lines plusO(points) for the dots; with a few hundred points per series it stays well
within a single frame. For very dense datasets, set showDots={false} to halve
the node count, and consider down-sampling before passing data in.
State Flow Diagram
ββββββββββββββββββββββββββββββββ
β idle (activeIndex = null) β
ββββββββββββββββββββββββββββββββ
β pointermove / ArrowRight β pointerleave / blur / Escape
βΌ β²
ββββββββββββββββββββββββββββββββ β
β active (activeIndex = i) ββββββββ
β β’ crosshair at xValues[i] β
β β’ tooltip lists each series β
β β’ matching dot enlarges β
ββββββββββββββββββββββββββββββββ
β ArrowLeft/Right β clamp(i Β± 1)
β Home β 0 β’ End β last
βΌ
(stays active, index moves)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
series |
LineSeries[] |
[] |
Array of { name, colour, points: { x, y }[] }. |
height |
number |
320 |
Plot height in px. Width is responsive to the container. |
xLabel |
string |
'' |
Title rendered beneath the X axis (also the table's X header). |
yLabel |
string |
'' |
Title rendered rotated beside the Y axis. |
yTicks |
number |
5 |
Target number of Y gridlines; the nice-scale picks the real count. |
showDots |
boolean |
true |
Draw a marker at each data point. |
showLegend |
boolean |
true |
Show the colour-swatch legend below the chart. |
formatX |
(x: number) => string |
String |
Formats X tick + tooltip labels. |
formatY |
(y: number) => string |
compact (12k) |
Formats Y tick + tooltip values. |
ariaLabel |
string |
auto-generated | Accessible name for the chart image + table caption. |
class |
string |
'' |
Extra class on the wrapper. |
LineSeries and LinePoint are declared inline in the component:
interface LinePoint { x: number; y: number; }
interface LineSeries { name: string; colour: string; points: LinePoint[]; }Edge Cases
| Case | Behaviour |
|---|---|
series is empty |
Renders a centred "No data to display." message; no SVG, legend or table. |
| A single point / single x-value | The point is centred horizontally rather than pinned to the left edge. |
| All y-values identical (flat line) | The scale pads above and below so the line sits mid-plot, not on an edge. |
| A series skips an x-value present in others | Its tooltip and table cell show β; the path simply omits that segment. |
| Negative values | Handled by the nice-scale; the zero baseline is included when natural. |
ResizeObserver unavailable |
Falls back to the initial 640px width; chart still renders (test-safe). |
| Cursor between two points | Crosshair snaps to whichever x-pixel is nearest β never lands between slots. |
Dependencies
Zero external dependencies. Pure Svelte 5 runes, scoped CSS, and inline SVG.
The only platform API used is ResizeObserver, which is guarded so the
component still renders where it is absent (e.g. SSR / jsdom).
File Structure
src/lib/components/
LineChart.svelte β component (geometry, nice-scale, crosshair, a11y table)
LineChart.md β this explainer
LineChart.test.ts β vitest + @testing-library/svelte coverage
src/routes/linechart/
+page.svelte β demo page wrapped in ComponentPageShellAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
series | LineSeries[] | [] | Array of { name, colour, points: { x, y }[] }. |
height | number | 320 | Plot height in px; width is responsive to the container. |
xLabel | string | '' | Title beneath the X axis (also the table's X header). |
yLabel | string | '' | Title rotated beside the Y axis. |
yTicks | number | 5 | Target number of Y gridlines (nice-scaled). |
showDots | boolean | true | Draw a marker at each data point. |
showLegend | boolean | true | Show the colour-swatch legend below the chart. |
formatX | (x: number) => string | String | Formats X tick + tooltip labels. |
formatY | (y: number) => string | compact (12k) | Formats Y tick + tooltip values. |
ariaLabel | string | auto-generated | Accessible name for the chart image + table caption. |
class | string | '' | Extra CSS class on the wrapper. |