Chart-building with D3 and Svelte 5
14 years after its release in 2011, D3 is still a foundational tool for data visualisation on the web.
Simple line charts, complex interactive maps (like this one), and everything in between: D3’s doing most of the complicated work for you.
Part of D3’s appeal is its DOM manipulation API (d3-selection), which gives us the tools to transform data into SVG visualisations.
Here’s a simple example. This code imports data from a JSON file, then populates an empty <svg>
element with <path>
, <line>
, etc. elements mapped from the data (I’ve left out the code for the title and legend to keep things short).
import * as d3 from "d3"; import transfers from "./transfers-top-spenders.json"; const margin = { right: 20, top: 10, left: 40, bottom: 20 }; const width = 400 - margin.left - margin.right; const height = 280 - margin.top - margin.bottom; // ["15/16", "16/17", "17/18", ... ] const seasons = [...new Set(transfers.map((d) => d.season))]; const x = d3.scalePoint().domain(seasons).range([0, width]); const y = d3.scaleLinear().domain([0, 700]).range([height, 0]); const line = d3.line().x((d) => x(d.season)).y((d) => y(d.spend)); const svg = d3.select("#transfer-window") .attr("width", width + margin.left + margin.right) .attr("height", height + margin.bottom + margin.top) .append("g") .attr("transform", `translate(${margin.left},${margin.top})`); svg.selectAll() .data(d3.group(transfers, (d) => d.team)) .join("path") .attr("d", ([_, values]) => line(values)) .attr("data-team", ([team]) => team) svg.append("g").call(d3.axisLeft(y)); svg.append("g") .attr("transform", `translate(0,${height})`) .call(d3.axisBottom(x));
[ { "team": "mnu", "spend": 156, "arrivals": 21, "income": 101, "season": "15/16" }, { "team": "chl", "spend": 96, "arrivals": 54, "income": 87, "season": "15/16" }, { "team": "liv", "spend": 126, "arrivals": 30, "income": 91, "season": "15/16" }, { "team": "mnc", "spend": 208, "arrivals": 29, "income": 67, "season": "15/16" }, { "team": "ars", "spend": 26, "arrivals": 16, "income": 2, "season": "15/16" }, { "team": "ars", "spend": 113, "arrivals": 13, "income": 10, "season": "16/17" }, { "team": "mnc", "spend": 216, "arrivals": 42, "income": 35, "season": "16/17" }, { "team": "mnu", "spend": 185, "arrivals": 10, "income": 47, "season": "16/17" }, { "team": "chl", "spend": 133, "arrivals": 41, "income": 109, "season": "16/17" }, { "team": "liv", "spend": 80, "arrivals": 23, "income": 89, "season": "16/17" }, { "team": "liv", "spend": 174, "arrivals": 16, "income": 184, "season": "17/18" }, { "team": "mnc", "spend": 318, "arrivals": 38, "income": 91, "season": "17/18" }, { "team": "ars", "spend": 153, "arrivals": 15, "income": 162, "season": "17/18" }, { "team": "chl", "spend": 260, "arrivals": 47, "income": 195, "season": "17/18" }, { "team": "mnu", "spend": 198, "arrivals": 13, "income": 46, "season": "17/18" }, { "team": "mnu", "spend": 83, "arrivals": 11, "income": 31, "season": "18/19" }, { "team": "ars", "spend": 80, "arrivals": 19, "income": 9, "season": "18/19" }, { "team": "liv", "spend": 192, "arrivals": 22, "income": 41, "season": "18/19" }, { "team": "mnc", "spend": 79, "arrivals": 28, "income": 58, "season": "18/19" }, { "team": "chl", "spend": 209, "arrivals": 41, "income": 83, "season": "18/19" }, { "team": "mnc", "spend": 170, "arrivals": 38, "income": 71, "season": "19/20" }, { "team": "liv", "spend": 10, "arrivals": 21, "income": 48, "season": "19/20" }, { "team": "chl", "spend": 45, "arrivals": 37, "income": 165, "season": "19/20" }, { "team": "mnu", "spend": 237, "arrivals": 16, "income": 81, "season": "19/20" }, { "team": "ars", "spend": 161, "arrivals": 20, "income": 54, "season": "19/20" }, { "team": "ars", "spend": 86, "arrivals": 20, "income": 19, "season": "20/21" }, { "team": "liv", "spend": 85, "arrivals": 21, "income": 17, "season": "20/21" }, { "team": "mnu", "spend": 84, "arrivals": 21, "income": 20, "season": "20/21" }, { "team": "mnc", "spend": 173, "arrivals": 39, "income": 64, "season": "20/21" }, { "team": "chl", "spend": 267, "arrivals": 43, "income": 57, "season": "20/21" }, { "team": "mnc", "spend": 139, "arrivals": 36, "income": 94, "season": "21/22" }, { "team": "chl", "spend": 118, "arrivals": 25, "income": 158, "season": "21/22" }, { "team": "liv", "spend": 89, "arrivals": 15, "income": 32, "season": "21/22" }, { "team": "ars", "spend": 167, "arrivals": 22, "income": 31, "season": "21/22" }, { "team": "mnu", "spend": 142, "arrivals": 20, "income": 31, "season": "21/22" }, { "team": "chl", "spend": 630, "arrivals": 35, "income": 68, "season": "22/23" }, { "team": "mnc", "spend": 155, "arrivals": 33, "income": 162, "season": "22/23" }, { "team": "ars", "spend": 186, "arrivals": 32, "income": 24, "season": "22/23" }, { "team": "liv", "spend": 146, "arrivals": 12, "income": 80, "season": "22/23" }, { "team": "mnu", "spend": 243, "arrivals": 27, "income": 24, "season": "22/23" }, { "team": "chl", "spend": 464, "arrivals": 39, "income": 282, "season": "23/24" }, { "team": "mnc", "spend": 260, "arrivals": 24, "income": 139, "season": "23/24" }, { "team": "liv", "spend": 172, "arrivals": 19, "income": 61, "season": "23/24" }, { "team": "mnu", "spend": 211, "arrivals": 22, "income": 64, "season": "23/24" }, { "team": "ars", "spend": 235, "arrivals": 17, "income": 69, "season": "23/24" }, { "team": "mnc", "spend": 243, "arrivals": 22, "income": 142, "season": "24/25" }, { "team": "ars", "spend": 109, "arrivals": 16, "income": 84, "season": "24/25" }, { "team": "chl", "spend": 282, "arrivals": 44, "income": 239, "season": "24/25" }, { "team": "liv", "spend": 42, "arrivals": 18, "income": 47, "season": "24/25" }, { "team": "mnu", "spend": 246, "arrivals": 22, "income": 117, "season": "24/25" }, { "team": "mnu", "spend": 230, "arrivals": 14, "income": 0, "season": "25/26" }, { "team": "chl", "spend": 280, "arrivals": 27, "income": 256, "season": "25/26" }, { "team": "ars", "spend": 293, "arrivals": 14, "income": 8, "season": "25/26" }, { "team": "liv", "spend": 340, "arrivals": 16, "income": 220, "season": "25/26" }, { "team": "mnc", "spend": 177, "arrivals": 18, "income": 58, "season": "25/26" } ]
Transfer window spend (in millions of £)
- Manchester United
- Chelsea
- Liverpool
- Manchester United
- Arsenal
But things have changed a bit since D3 was released. These days we’re usually building websites with React, Vue, or Svelte (if you’re cool enough).
To make the code above work inside these frameworks, we wait for our components to be rendered in the DOM before asking D3 to do its thing.
In Svelte, we do this with onMount
(equivalent to React’s useEffect
).
<script>
import * as d3 from "d3";
import transfers from "./transfers-top-spenders.json";
onMount(() => {
const margin = { right: 20, top: 10, left: 40, bottom: 20 };
const width = 400 - margin.left - margin.right;
const height = 280 - margin.top - margin.bottom;
const seasons = [...new Set(transfers.map((d) => d.season))];
const x = d3.scalePoint().domain(seasons).range([0, width]);
const y = d3.scaleLinear().domain([0, 700]).range([height, 0]);
const line = d3.line().x((d) => x(d.season)).y((d) => y(d.spend));
const svg = d3.select("#transfer-window")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.bottom + margin.top)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
svg.selectAll()
.data(d3.group(transfers, (d) => d.team))
.join("path")
.attr("d", ([_, values]) => line(values))
.attr("data-team", ([team]) => team)
svg.append("g").call(d3.axisLeft(y));
svg.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(x));
})
</script>
<svg id="transfer-window"></svg>
This works, and it’s what I often see in code that uses D3 in Svelte.
But really, we should be asking Svelte to render our elements, not D3. That’s Svelte’s job after all. D3’s own getting started guide tells us as much.
And not just because it’s cleaner or more consistent — there are tangible benefits: better performance, easier reactivity, easier animations.
I’ll talk more about the benefits further down, but first, let’s see how it looks to do things properly.
<script>
const x = d3.scalePoint().domain(seasons).range([0, width]); const y = d3.scaleLinear().domain([0, 700]).range([height, 0]); const line = d3.line().x((d) => x(d.season)).y((d) => y(d.spend)); </script> <svg height={height + margin.top + margin.bottom} width={width + margin.left + margin.right}> <g transform={`translate(${margin.left},${margin.top})`}> {#each d3.group(transfers, (d) => d.team) as [team, series]} <path d={line(series)} data-team={team} /> {/each} <line y2={height} class="domain" /> {#each y.ticks() as tick} <g class="tick" transform={`translate(0,${y(tick)})`}> <line x2="-6" /> <text x="-10" y="4" text-anchor="end">{tick}</text> </g> {/each} <g transform={`translate(0,${height})`}> <line x2={width} class="domain" /> {#each x.domain() as tick} <g class="tick" transform={`translate(${x(tick)},0)`}> <line y2="6" /> <text y="20" text-anchor="middle">{tick}</text> </g> {/each} </g> </g> </svg>import * as d3 from "d3";
import transfers from "./transfers-top-spenders.json"; const margin = { right: 20, top: 10, left: 40, bottom: 20 }; const width = 400 - margin.left - margin.right; const height = 280 - margin.top - margin.bottom; const seasons = [...new Set(transfers.map((d) => d.season))];
Looking at the key bits of code:
-
for each team’s data in
transfers
, we draw a<path>
whose shape (d
) is given by callingline(series)
-
draw each tick given by
y.ticks()
, and a vertical line -
draw each tick given by
x.domain()
, and a horizontal line
We’re still using D3 to transform our data and prepare the functions that delivers it to us (eg. x.domain()
), but we’re drawing all of the SVG elements “ourselves” in Svelte.
Now, why is this better?
Server-side rendering (SSR)
The first and most significant benefit is our charts can be rendered on the server. This means our chart is already present in the page’s HTML when it lands in our reader’s browser.
When we render charts using D3’s DOM API inside onMount
, as we saw before, we’re asking Svelte to “wait until you land on the browser and finish rendering your HTML, before running this D3 code that draws a chart”.
This comes with a number of drawbacks:
-
cumulative layout shift when our chart takes up the space it needs and moves neighbouring elements around
-
more time before the chart appears, as the browser downloads the data required to draw the chart
-
SEO penalties due to content not being initially available in the page, particularly if charts contain a lot of important content
We can mitigate these points individually, but it’s much easier to just render the chart along with the rest of our page on the server.
Animating charts with svelte/transition
Rendering our chart with Svelte opens the door to the rest of its powerful tools and functionality, like svelte/transition
.
This library offers a bunch of functions that make it easy to transition elements when they’re added or removed from the DOM.
One of these is the draw
function. Let’s see what it can do for our line chart.
<script>
let showLines = $state(false); </script> <svg width={width + margin.left + margin.right} height={height + margin.bottom + margin.top} {@attach whenVisible((visible) => (showLines = visible))}> <g transform={`translate(${margin.left},${margin.top})`}> {#each d3.group(transfers, (d) => d.team) as [team, series], i} {#if showLines} <path in:draw={{ delay: (i + 1) * 150 }} d={line(series)} data-team={team} /> {/if} {/each}import * as d3 from "d3";
import { draw } from "svelte/transition"; import transfers from "./transfers-top-spenders.json"; import { whenVisible } from "./when-visible.svelte"; const margin = { right: 20, top: 10, left: 40, bottom: 20 }; const width = 400 - margin.left - margin.right; const height = 280 - margin.top - margin.bottom; const seasons = [...new Set(transfers.map((d) => d.season))]; const x = d3.scalePoint().domain(seasons).range([0, width]); const y = d3.scaleLinear().domain([0, 700]).range([height, 0]); const line = d3.line().x((d) => x(d.season)).y((d) => y(d.spend));<line y2={height} class="domain" />
{#each y.ticks() as tick} <g class="tick" transform={`translate(0,${y(tick)})`}> <line x2="-6" /> <text x="-10" y="4" text-anchor="end">{tick}</text> </g> {/each} <g transform={`translate(0,${height})`}> <line x2={width} class="domain" /> {#each x.domain() as tick} <g class="tick" transform={`translate(${x(tick)},0)`}> <line y2="6" /> <text y="20" text-anchor="middle">{tick}</text> </g> {/each} </g> </g> </svg>
/** @param {(visible: boolean) => void} callback */ export function whenVisible(callback) { return (element) => { const observer = new IntersectionObserver((entries) => { callback(entries[0].isIntersecting); if (entries[0].isIntersecting) { observer.unobserve(element); } }); observer.observe(element); return () => { observer.disconnect(); }; }; }
Here, we’re wrapping each <path>
in an {#if}
statement so they’re all added to or removed from the DOM according to showLines
. in:draw
tells Svelte to animate the chosen element when it’s added to the DOM.
That {{ delay: (i + 1) * 150 }}
bit is us giving parameters to the effect, adding an increasing delay to each of the five lines.
Alongside draw
, there’s also fade
, fly
, slide
, and others, all of which can be used just as easily to add some flair to our visualisations.
Reacting to changing data with runes
Rendering charts with Svelte also makes state management and reactivity easier, using Svelte 5’s $state
and $derived
runes.
We used $state
in the last example to show and hide our chart’s lines, but let’s look at a more involved example.
<script>
let stat = $state("spend"); let maxStat = $derived(Math.max(...transfers.map((d) => d[stat]))); let line = $derived( d3.line().x((d) => x(d.season)).y((d) => y(d[stat])) ); let y = $derived( d3.scaleLinear().domain([0, maxStat]).range([height, 0]) ); </script>import * as d3 from "d3";
import transfers from "./transfers-top-spenders.json"; const margin = { right: 20, top: 10, left: 40, bottom: 20 }; const width = 400 - margin.left - margin.right; const height = 280 - margin.top - margin.bottom; const seasons = [...new Set(transfers.map((d) => d.season))]; const x = d3.scalePoint().domain(seasons).range([0, width]);<div class="controls"> <span>Show data for</span> {#each ["spend", "income", "arrivals"] as option} <button aria-pressed={stat === option} onclick={() => (stat = option)}> {option} {option === "arrivals" ? "(#)" : "(£m)"} </button> {/each} </div><svg
width={width + margin.left + margin.right} height={height + margin.bottom + margin.top}> <g transform={`translate(${margin.left},${margin.top})`}> {#each d3.group(transfers, (d) => d.team) as [team, series]} <path d={line(series)} data-team={team} /> {/each} <line y2={height} class="domain" /> {#each y.ticks() as tick} <g class="tick" transform={`translate(0,${y(tick)})`}> <line x2="-6" /> <text x="-10" y="4" text-anchor="end">{tick}</text> </g> {/each} <g transform={`translate(0,${height})`}> <line x2={width} class="domain" /> {#each x.domain() as tick} <g class="tick" transform={`translate(${x(tick)},0)`}> <line y2="6" /> <text y="20" text-anchor="middle">{tick}</text> </g> {/each} </g> </g> </svg>
Here, we’re displaying three buttons that set the stat
variable to one of three strings. Each of these strings are properties in the transfers
JSON data.
Using $derived
, whenever stat
changes:
maxStat
will become the maximum value of that property in the datasetline
will become a new line generator function for that property
And whenever maxStat
changes:
y
will become a generator function for the Y axis
Really, all we’re doing is re-creating the D3 generator functions whenever we want to show different data, by making them reactive variables using $state
and $derived
.
Notice that we haven’t actually made any changes to the SVG-drawing code itself — we don’t have to! Any HTML code that refers to line
and y
is automatically re-run when they change.
Animating changes in data with svelte/motion
svelte/motion
is another one of Svelte’s powerful extra libraries.
In this party bag, we have the Tween
tool, which gives us a reactive variable that smoothly changes from one value to another over time.
This works for numbers, but it also works for structured data, like objects and arrays.
Let’s see what this can do for our chart.
<script>
let stat = $state("spend"); let statData = Tween.of(() => transfers.map((d) => ({ ...d, stat: d[stat] })), ); let maxStat = Tween.of(() => Math.max(...transfers.map((d) => d[stat]))); let line = $derived( d3.line().x((d) => x(d.season)).y((d) => y(d.stat)), ); let y = $derived( d3.scaleLinear().domain([0, maxStat.current]).range([height, 0]), ); </script> <svg width={width + margin.left + margin.right} height={height + margin.bottom + margin.top}> <g transform={`translate(${margin.left},${margin.top})`}> {#each d3.group(statData.current, (d) => d.team) as [team, series]} <path d={line(series)} data-team={team} /> {/each}import * as d3 from "d3";
import { Tween } from "svelte/motion"; import transfers from "./transfers-top-spenders.json"; const margin = { right: 20, top: 10, left: 40, bottom: 20 }; const width = 400 - margin.left - margin.right; const height = 280 - margin.top - margin.bottom; const seasons = [...new Set(transfers.map((d) => d.season))]; const x = d3.scalePoint().domain(seasons).range([0, width]);<line y2={height} class="domain" />
{#each y.ticks() as tick} <g class="tick" transform={`translate(0,${y(tick)})`}> <line x2="-6" /> <text x="-10" y="4" text-anchor="end">{tick}</text> </g> {/each} <g transform={`translate(0,${height})`}> <line x2={width} class="domain" /> {#each x.domain() as tick} <g class="tick" transform={`translate(${x(tick)},0)`}> <line y2="6" /> <text y="20" text-anchor="middle">{tick}</text> </g> {/each} </g> </g> </svg> <div class="controls"> <span>Show data for</span> {#each ["spend", "income", "arrivals"] as option} <button aria-pressed={stat === option} onclick={() => (stat = option)}> {option} {option === "arrivals" ? "(#)" : "(£m)"} </button> {/each} </div>
There’s a lot going on here: let’s step through.
Whenever stat
changes, transfers.map((d) => ({ ...d, stat: d[stat] }))
is re-evaluated, returning the new dataset.
Tween.of
takes this new dataset, and generates a sequence of datasets that smoothly transition from the last value to the current.
Over a period of 400 milliseconds (a default value), the tween rapidly steps through this sequence, setting statData.current
with every step. statData.current
is a reactive value, so whenever it changes, the <path>
s are redrawn for the current dataset.
These steps happen very quickly. So quickly that all we see is a smooth morph animation between two charts.
maxStat
becomes a reactive tween as well, but this one’s just a number that changes between the maximum value of the selected dataset.