LineChart

Responsive SVG line chart with axes, gridlines, and a hover crosshair tooltip.

Live demo

01

Two 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
Line chart with 2 series: Revenue, Costs.
QuarterRevenueCosts
Q1129
Q21911
Q31712
Q42414
Q52816
Q62617
Q73319
Q83922

Single series β€” negatives & nice-scale

Temperature anomaly with negative values; the auto scale keeps a tidy zero-crossing baseline.

  • Anomaly Β°C
Line chart with 1 series: Anomaly Β°C.
YearAnomaly Β°C
2020-0.3Β°
20210.1Β°
20220.6Β°
20230.4Β°
20241.1Β°
20251.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
Line chart with 3 series: Organic, Paid, Referral.
MonthOrganicPaidReferral
11.2k800300
21.8k950420
32.4k1.1k380
42.1k1.6k510
53.2k1.4k640
64.1k1.9k720

Implementation

02
LineChart.svelte
<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

03

What 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 β†’ clear

The 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 use
vector-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 plus
O(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 ComponentPageShell

API

04
PropTypeDefaultDescription
seriesLineSeries[][]Array of { name, colour, points: { x, y }[] }.
heightnumber320Plot height in px; width is responsive to the container.
xLabelstring''Title beneath the X axis (also the table's X header).
yLabelstring''Title rotated beside the Y axis.
yTicksnumber5Target number of Y gridlines (nice-scaled).
showDotsbooleantrueDraw a marker at each data point.
showLegendbooleantrueShow the colour-swatch legend below the chart.
formatX(x: number) => stringStringFormats X tick + tooltip labels.
formatY(y: number) => stringcompact (12k)Formats Y tick + tooltip values.
ariaLabelstringauto-generatedAccessible name for the chart image + table caption.
classstring''Extra CSS class on the wrapper.