Create a dual axis chart with Svelte 5

In this tutorial we will combine a barchart with a line chart to create a dual axis chart like below.

JanAprJulOct0500010000150002000025000$ Sales01000200030004000$ Profits

Let’s first create our axis components, we will need left, bottom and right axis for our chart. And those will be built by using a Svg <line> and <text> elements which will create the main axis line, axis ticks and tick labels.

Left y-axis.

<!-- AxisLeftV5.svelte -->
<script>
  let { yScale, margin, ticksNumber = 5 } = $props();
</script>

{#if yScale}
  <g transform="translate({margin.left},0)">
    <!-- yScale.domain() is an array with two elements min and max values 
    from the data, so we can use it to create the start and end point
    of our axis line -->
    <line
      stroke="currentColor"
      y1={yScale(yScale.domain()[0])}
      y2={yScale(yScale.domain()[1])} />
    <!-- Specify the number of ticks here -->
    {#each yScale.ticks(ticksNumber) as tick}
      {#if tick !== 0}
        <line
          stroke="currentColor"
          x1={0}
          x2={-6}
          y1={yScale(tick)}
          y2={yScale(tick)} />
      {/if}

      <text
        fill="currentColor"
        text-anchor="end"
        font-size="10"
        dominant-baseline="middle"
        x={-9}
        y={yScale(tick)}>
        {tick}
      </text>
    {/each}
  </g>
{/if}

Right y-axis.

<!-- AxisRightV5.svelte -->
<script>
  // Props
  let { yScale, width, margin, ticksNumber = 5, format = null } = $props();

  // Conditionally apply the formatter if provided
  const formatter = format
    ? (tick) => format(tick) // Use the provided formatter
    : (tick) => tick; // Default: no formatting
</script>

{#if yScale}
  <g transform="translate({width - margin.right},0)">
    <!-- Axis line -->
    <line
      stroke="currentColor"
      stroke-width="1px"
      y1={yScale(0)}
      y2={yScale(yScale.domain()[1])} />

    <!-- Ticks -->
    {#each yScale.ticks(ticksNumber) as tick}
      {#if tick !== 0}
        <line
          stroke-width="1px"
          stroke="currentColor"
          x1={0}
          x2={6}
          y1={yScale(tick)}
          y2={yScale(tick)} />
      {/if}

      <!-- Tick labels -->
      <text
        fill="currentColor"
        text-anchor="start"
        font-size="10"
        dominant-baseline="middle"
        x={12}
        y={yScale(tick)}>
        {formatter(tick)}
      </text>
    {/each}
  </g>
{/if}

Bottom axis will be slightly different since scaleBand() does not have the .ticks() method. In this case we will use xScale.domain() to create the axis ticks.

We have two utility functions filterFunc and formatFunc to prevent overlapping text. For example instead of showing each month in the axis like January, February, March etc. we will show every third month and shorten the month name to three characters.

Those utility functions are props that we will pass to our main chart component.

For instance:

// formatFunc to shorten month names
(d) => d.slice(0, 3)

// filterFunc to show each third tick
(d, i) => i % 3 === 0
<!-- AxisBandBottomV5.svelte -->
<script>
  // Props
  let {
    xScale,
    margin,
    height,
    width,
    format = null,
    filterFunc = () => true
  } = $props();

  // Conditionally apply the formatter if provided
  const formatter = format
    ? (tick) => format(tick) // Use the provided formatter
    : (tick) => tick; // Default: no formatting

  // Function to filter the ticks based on the provided filter function
  // This is initialized once and won't react to changes in `xScale` or `filterFunc`.
  // If you need to respond to user interactions (e.g., show only 1, 3, or 6 months),
  // e.g via a dropdown menu. consider using a reactive declaration or `$derived`
  // to recompute `filteredDomain`.
  let filteredDomain = xScale ? xScale.domain().filter(filterFunc) : [];
</script>

{#if xScale}
  <g transform="translate(0,{height - margin.bottom})">
    <!-- Axis line -->
    <line stroke="currentColor" x1={margin.left} x2={width - margin.right} />

    <!-- All ticks -->
    {#each xScale.domain() as tick}
      <line
        stroke="currentColor"
        x1={xScale(tick) + xScale.bandwidth() / 2}
        x2={xScale(tick) + xScale.bandwidth() / 2}
        y1={0}
        y2={6} />
    {/each}

    <!-- Filtered labels -->
    {#each filteredDomain as tick}
      <text
        font-size="12px"
        fill="currentColor"
        text-anchor="middle"
        x={xScale(tick) + xScale.bandwidth() / 2}
        y={16}>
        {formatter(tick)}
      </text>
    {/each}
  </g>
{/if}

Now we have axis components ready and here is the monthly sales and profit data that we are going to use.

// sales_data.json
// copy by clicking the copy icon on the right corner
// or download on https://datavisualizationwithsvelte.com/data/sales_data.json
[
  {
    "month": "January",
    "sales": 5000,
    "profit": 1200
  },
  {
    "month": "February",
    "sales": 7000,
    "profit": 1500
  },
  {
    "month": "March",
    "sales": 8000,
    "profit": 1800
  },
  {
    "month": "April",
    "sales": 12000,
    "profit": 2000
  },
  {
    "month": "May",
    "sales": 15000,
    "profit": 3000
  },
  {
    "month": "June",
    "sales": 20000,
    "profit": 4000
  },
  {
    "month": "July",
    "sales": 22000,
    "profit": 3500
  },
  {
    "month": "August",
    "sales": 18000,
    "profit": 3700
  },
  {
    "month": "September",
    "sales": 16000,
    "profit": 3100
  },
  {
    "month": "October",
    "sales": 19000,
    "profit": 3300
  },
  {
    "month": "November",
    "sales": 23000,
    "profit": 3600
  },
  {
    "month": "December",
    "sales": 25000,
    "profit": 3900
  }
]

If you create a sales_data.json file you can import it directly to your svelte component like import data from '$lib/data/sales_data.json'; if you have "resolveJsonModule": true, in your tsconfig.json file.

Here is the final code to bring together axis components.

<script>
  import data from '$lib/data/sales_data.json';
  import { scaleBand, scaleLinear } from 'd3-scale';
  import { max } from 'd3-array';
  import { line, curveBasis } from 'd3-shape';
  import AxisBottom from './AxisBottomBandV5.svelte';
  import AxisLeft from './AxisLeftV5.svelte';
  import AxisRight from './AxisRightV5.svelte';

  // State variables
  let width = $state(400); // width will be set by the clientWidth
  const height = 350;
  const margin = { top: 40, right: 30, bottom: 20, left: 50 };

  // Computed values
  let xScale = $derived(
    data && width
      ? scaleBand()
          .domain(data.map((d) => d.month))
          .range([margin.left, width - margin.right])
          .padding(0.1)
      : null
  );

  let yScale1 = $derived(
    data && width
      ? scaleLinear()
          .domain([0, max(data, (d) => d.sales)])
          .range([height - margin.bottom, margin.top])
      : null
  );

  let yScale2 = $derived(
    data && width
      ? scaleLinear()
          .domain([0, max(data, (d) => d.profit)])
          .range([height - margin.bottom, margin.top])
      : null
  );
</script>

<div class="wrapper" bind:clientWidth={width}>
  {#if data && width}
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}>
      <AxisBottom
        {width}
        {height}
        {margin}
        {xScale}
        format={(d) => d.slice(0, 3)}
        filterFunc={(d, i) => i % 3 === 0} />
      <AxisLeft {width} {height} {margin} yScale={yScale1} />
      <AxisRight {width} {margin} yScale={yScale2} />
    </svg>
  {/if}
</div>
JanAprJulOct050001000015000200002500001000200030004000

Now let’s add the bar and line chart and axis labels to our Svg. We are reusing the labels.svelte compoment from the Scatter Chart I tutorial.

<script>
  import data from '$lib/data/sales_data.json';
  import { scaleBand, scaleLinear } from 'd3-scale';
  import { max } from 'd3-array';
  import { line, curveBasis } from 'd3-shape';
  import AxisBottom from './AxisBottomBandV5.svelte';
  import AxisLeft from './AxisLeftV5.svelte';
  import AxisRight from './AxisRightV5.svelte';
  import Labels from './Labels.svelte';

  // State variables
  let width = $state(400); // width will be set by the clientWidth
  const height = 350;
  const margin = { top: 40, right: 30, bottom: 20, left: 50 };

  // Computed values
  let xScale = $derived(
    data && width
      ? scaleBand()
          .domain(data.map((d) => d.month))
          .range([margin.left, width - margin.right])
          .padding(0.1)
      : null
  );

  let yScale1 = $derived(
    data && width
      ? scaleLinear()
          .domain([0, max(data, (d) => d.sales)])
          .range([height - margin.bottom, margin.top])
      : null
  );

  let yScale2 = $derived(
    data && width
      ? scaleLinear()
          .domain([0, max(data, (d) => d.profit)])
          .range([height - margin.bottom, margin.top])
      : null
  );

  let linePath = $derived(
    xScale && yScale2
      ? line()
          .x((d) => xScale(d.month) + xScale.bandwidth() / 2)
          .y((d) => yScale2(d.profit))
          .curve(curveBasis)
      : null
  );
</script>

<div class="wrapper" bind:clientWidth={width}>
  {#if data && width && xScale && yScale1 && yScale2 && linePath}
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}>
      <AxisBottom
        {width}
        {height}
        {margin}
        {xScale}
        format={(d) => d.slice(0, 3)}
        filterFunc={(d, i) => i % 3 === 0} />
      <AxisLeft {width} {height} {margin} yScale={yScale1} />
      <Labels
        labelfory={true}
        {width}
        {height}
        {margin}
        yoffset={-15}
        xoffset={0}
        label={'$ Sales'}
        fillColor="steelblue" />
      <AxisRight {width} {margin} yScale={yScale2} />
      <Labels
        labelfory={true}
        {width}
        {height}
        {margin}
        yoffset={-15}
        xoffset={width - 28}
        label={'$ Profits'}
        fillColor="red" />

      {#each data as d}
        <rect
          x={xScale(d.month)}
          y={yScale1(d.sales)}
          width={xScale.bandwidth()}
          height={yScale1(0) - yScale1(d.sales)}
          fill="steelblue" />
      {/each}

      <path d={linePath(data)} fill="none" stroke="red" stroke-width="2" />
    </svg>
  {/if}
</div>

here is our Labels.svelte component:

<!-- Labels.svelte -->
<script>
  // Declare props using $props
  let {
    width,
    height,
    margin,
    label,
    yoffset = 0,
    xoffset = 0,
    labelforx = false,
    labelfory = false,
    textanchor = 'end',
    fillColor = 'white'
  } = $props();
</script>

{#if width}
  <g class="labels-x-y text-sm">
    {#if labelforx}
      <text
        fill={fillColor}
        x={+margin.left + width - 66 + xoffset}
        y={+height + margin.top - 40 + yoffset}
        text-anchor={textanchor}>
        {label}
      </text>
    {/if}
    {#if labelfory}
      <text
        fill={fillColor}
        x={+margin.left + xoffset}
        y={-margin.top + 80 + yoffset}
        text-anchor={textanchor}>
        {label}
      </text>
    {/if}
  </g>
{/if}

Join our Discord to share your charts, ask questions, or collaborate with fellow developers!

Please use dual axis charts sparingly and only if you have a compelling reason, as they can be difficult to interpret. Dual axis charts often lead to misinterpretation because they combine two different scales, making it challenging to compare data accurately. Alternatives like side-by-side charts, indexed charts, or connected scatterplots are usually more effective for clear and reliable data representation. For more information, refer here.