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