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"
  }
]
Result

Transfer window spend (in millions of £) by top 5 UK football clubs, by season

  • 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).

Svelte
    <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.

Svelte
     
<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]); 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>

Looking at the key bits of code:

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:

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>
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));
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}
<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();
    };
  };
}
Result
05010015020025030035040045050055060065070015/1616/1717/1818/1919/2020/2121/2222/2323/2424/2525/26

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.

Svelte
     
<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]);
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>
<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>
<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>
Result
05010015020025030035040045050055060015/1616/1717/1818/1919/2020/2121/2222/2323/2424/2525/26
Show data for

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:

And whenever maxStat changes:

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.

Svelte
     
<script>
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]);
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}
<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>
Result
05010015020025030035040045050055060015/1616/1717/1818/1919/2020/2121/2222/2323/2424/2525/26
Show data for

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.

a diagram showing the intermediate datasets generated by Svelte's tween feature a diagram showing the intermediate datasets generated by Svelte's tween feature

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.