Create a Stacked Bar Chart
A stacked bar chart is an ideal choice when you want to compare overall values across categories while also highlighting the contribution of subcategories within each group. Unlike a standard bar chart, which only shows total values for each category, a stacked bar chart provides a clear view of how individual subcategories vary, making it perfect for visualizing both the total sales and the variation across travel types in different seasons.
By the end of this tutorial, you will be able to create a stacked bar chart using Svelte 5 and D3, complete with legends and axis labels.
Seasonal Sales Trends by Travel Type
Code Deep Dive
Use of $state and $derived runes
Unlike in Svelte 4 export let prop
, in Svelte 5, props are declared with the $props()
function. Let’s make the title and category labels as a prop so we can set it from a parent component if we need in the future.
<script>
//
// Props with default values
let {
title = 'Seasonal Sales Trends by Travel Type',
labelCategories = ['Flight', 'Train', 'Ship', 'Hotel', 'Bus']
} = $props();
//
</script>
We are using the $state rune to declare reactive values that can change dynamically. In this case, the width
value is tied to the bind:clientWidth
binding in Svelte, ensuring it updates automatically whenever the container’s width changes.
For the xScale we use the $derived
rune because the scale depends on the width
value, which is reactive. Whenever width
changes (e.g., due to resizing), the scale is recalculated automatically.
<script>
// Dimensions of the chart
let width = $state(480); // Chart width (reactive using Svelte's `$state`)
const height = 350; // Chart height (constant)
// Colors for each category in the stacked bar chart
const colors = ['#FFF84A', '#FF0266', '#FFC9C9', '#A8a1ff', '#45FFC8'];
// X Scale: Maps the 'Time Period' categories to horizontal positions
let xScale = $derived(
scaleBand()
.domain(data.map((d) => d['Time Period'])) // Categories (Winter, Spring, etc.)
.range([margin.left, width - margin.right])
.padding(0.2) // Adds padding between bars
);
// Y Scale: Maps the stacked sum of categories to vertical positions
const yScale = scaleLinear()
.domain([
0,
max(
data,
(d) => labelCategories.reduce((sum, key) => sum + d[key], 0) // Calculates the total for each season
)
])
.nice() // Adjusts the domain to end at a "nice" round number
.range([height - margin.bottom, margin.top]); // Pixel range for the y-axis (inverted as SVG origin is top-left)
//
</script>
Explanation of Stacking Logic
The stack()
function transforms the input data into layers that can be rendered as stacked bars.
- Splits the dataset into layers, one for each travel type (e.g., Flight, Train).
- For each layer, it computes:
y0
: Start position of the stack.y1
: End position of the stack.data
: Reference to the original data for the time period.
This allows us to represent both the total value of each main bar and the contribution of each travel type within it.
<script>
// Stack generator: Prepares the data for stacking
const stackGenerator = stack()
.keys(labelCategories) // Keys (Flight, Train, etc.) to stack on top of each other
.order(stackOrderNone); // No specific order for stacking
// Generates the stacked data structure for the chart
const stackedData = stackGenerator(data); // Array of layers for each category
$effect(() => console.log({ stackedData }));
</script>
For example, if we choose the second element from this array (Train) and highlight it:
Highlight only second element from the stackedData
Each Layer:
- Represents a single travel type (Flight, Train, Ship, Hotel, Bus).
- Contains arrays for each time period (Winter, Spring, Summer, Fall).
Each Array (
[y0, y1, data]
):y0
: The starting position for the layer.y1
: The ending position for the layer.data
: The original data for that time period.
The stack function calculates the total value for each main bar (e.g., Winter, Spring) and divides it into stack layers, one for each subcategory (e.g., Flight, Train). For each subcategory, it determines the start (y0) and end (y1) positions, enabling us to visually represent both the total values and individual contributions of each travel type for every season.
Full Code Preview
Let’s preview the full code first, then we can go step by step and explain the details.
<script>
import { scaleBand, scaleLinear } from 'd3-scale';
import { stack, stackOrderNone } from 'd3-shape'; // Functions to stack data for the chart
import { max } from 'd3-array';
import AxisLeft from './AxisLeftV5.svelte';
import XAxisLabel from './XAxisLabel.svelte';
import YAxisLabel from './YAxisLabel.svelte';
// Props with default values
let {
title = 'Seasonal Sales Trends by Travel Type',
labelCategories = ['Flight', 'Train', 'Ship', 'Hotel', 'Bus'],
singleStack = null
} = $props();
// Dataset representing seasonal sales data for different travel types
let data = [
{
'Time Period': 'Winter',
Flight: 15,
Train: 5,
Ship: 2,
Hotel: 20,
Bus: 6
},
{
'Time Period': 'Spring',
Flight: 25,
Train: 18,
Ship: 10,
Hotel: 28,
Bus: 12
},
{
'Time Period': 'Summer',
Flight: 60,
Train: 50,
Ship: 40,
Hotel: 70,
Bus: 35
},
{
'Time Period': 'Fall',
Flight: 35,
Train: 28,
Ship: 15,
Hotel: 40,
Bus: 20
}
];
// Margins around the chart to position it properly inside the SVG container
const margin = { top: 25, right: 30, bottom: 100, left: 32 };
// Dimensions of the chart
let width = $state(480); // Chart width (reactive using Svelte's `$state`)
const height = 370; // Chart height (constant)
// Colors for each category in the stacked bar chart
const colors = ['#FFF84A', '#FF0266', '#FFC9C9', '#A8a1ff', '#45FFC8'];
// X Scale: Maps the 'Time Period' categories to horizontal positions
let xScale = $derived(
scaleBand()
.domain(data.map((d) => d['Time Period'])) // Categories (Winter, Spring, etc.)
.range([margin.left, width - margin.right])
.padding(0.2) // Adds padding between bars
);
// Y Scale: Maps the stacked sum of categories to vertical positions
const yScale = scaleLinear()
.domain([
0,
max(
data,
(d) => labelCategories.reduce((sum, key) => sum + d[key], 0) // Calculates the total for each season
)
])
.nice() // Adjusts the domain to end at a "nice" round number
.range([height - margin.bottom, margin.top]); // Pixel range for the y-axis (inverted as SVG origin is top-left)
// Stack generator: Prepares the data for stacking
const stackGenerator = stack()
.keys(labelCategories) // Keys (Flight, Train, etc.) to stack on top of each other
.order(stackOrderNone); // No specific order for stacking
// Generates the stacked data structure for the chart
const stackedData = stackGenerator(data); // Array of layers for each category
let alternativeData = $state(null);
if (singleStack !== null) {
alternativeData = stackedData[singleStack];
}
</script>
<div
class=" p relative box-border min-w-full rounded-xl border-gray-100 p-4 pt-0"
bind:clientWidth={width}>
<div
class="flex w-full items-center justify-between pb-4 pt-1 font-semibold text-gray-600">
<h3 class="">{title}</h3>
</div>
<svg width={width - margin.left - margin.right} {height}>
<!-- Y-axis -->
<g>
{#each yScale.ticks(5) as tick}
<text
x={margin.left - 10}
y={yScale(tick)}
font-size="10px"
text-anchor="end"
alignment-baseline="middle">
<!-- {tick} -->
</text>
<line
class="stroke-gray-300"
stroke-dasharray="6,6"
x1={margin.left + 10}
x2={width - margin.right - margin.left}
y1={yScale(tick)}
y2={yScale(tick)} />
{/each}
</g>
<AxisLeft {width} {height} {margin} {yScale} ticksNumber={5} />
<!-- X and Y Axis Labels -->
<XAxisLabel {width} {height} {margin} label={'Seasons'} />
<YAxisLabel
{width}
{height}
{margin}
xoffset={0}
textanchor={'start'}
position={'top'}
label={'Total Sales ↑'} />
<!-- Bars and Total Values -->
<!-- Render all stacks -->
{#each stackedData as series, i}
{#each series as [y0, y1], j}
<rect
rx="3"
ry="3"
x={xScale(data[j]['Time Period'])}
y={yScale(y1)}
width={xScale.bandwidth()}
height={yScale(y0) - yScale(y1)}
opacity={singleStack !== null && i !== singleStack ? 0.1 : 1}
fill={colors[i]} />
{/each}
{/each}
<!-- X-axis labels -->
<g transform={`translate(0, ${height - margin.bottom})`}>
{#each xScale.domain() as period}
<text
class="fill-gray-300"
x={xScale(period) + xScale.bandwidth() / 2}
y="20"
font-size="14px"
text-anchor="middle">
{period}
</text>
{/each}
</g>
<!-- Category Labels with Color Indicators -->
<g transform={`translate(14, ${height - 20})`}>
{#each labelCategories as category, i}
<g
transform={`translate(${margin.left + (i * width) / 7 + xScale.bandwidth() - 70}, -4)`}>
<!-- Color box -->
<rect
style="border-radius:10px;"
width="16"
height="16"
rx="4"
ry="4"
fill={colors[i]} />
<!-- Category text -->
<text
class="fill-gray-300"
x="20"
y="10"
font-size="14px"
alignment-baseline="middle">{category}</text>
</g>
{/each}
</g>
</svg>
</div>
Rendering the bars
Now let’s look at the crucial part of the markup where we create the substacks.
- The outer loop iterates over the stackedData, representing each subcategory (Flight, Train).
- The inner loop iterates over each time period (Winter, Spring) to render rectangles for the subcategory.
So first we place all the rectangles representing the Flight category for all different seasons and this continues for all subcategories (Ship, Hotel, etc.).
<!-- Bars -->
{#each stackedData as series, i}
{#each series as [y0, y1], j}
<rect
rx="3"
ry="3"
x={xScale(data[j]['Time Period'])}
y={yScale(y1)}
width={xScale.bandwidth()}
height={yScale(y0) - yScale(y1)}
fill={colors[i]} />
{/each}
{/each}
Axis labels
The following bits are where we render labels for each main bar (e.g., Winter, Spring, etc.) by iterating over the xScale’s domain, which contains all the time periods.
The transform
attribute positions the labels just below the bars by moving the group down to the appropriate y-coordinate.
Each label is horizontally centered using text-anchor="middle"
and positioned based on the center of the corresponding bar using xScale(period) + xScale.bandwidth() / 2
.
<!-- X-axis labels -->
<g transform={`translate(0, ${height - margin.bottom})`}>
{#each xScale.domain() as period}
<text
class="fill-gray-300"
x={xScale(period) + xScale.bandwidth() / 2}
y="20"
font-size="14px"
text-anchor="middle">
{period}
</text>
{/each}
</g>
Add the legend
Then we render a legend for the chart. Each category (e.g., Flight, Train) is displayed with its corresponding color indicator.
We use a rect
element to create a small color box for each category, followed by a text
element to display the category name.
The labels are positioned just below the X-axis using transform
, with horizontal spacing calculated based on the width of the chart. This will allow them to space evenly in mobile or larger displays.
transform
attribute positions each legend item dynamically based on the chart width, ensuring consistent spacing across devices.
<!-- Category Labels with Color Indicators -->
<g transform={`translate(14, ${height - 20})`}>
{#each labelCategories as category, i}
<g
transform={`translate(${margin.left + (i * width) / 7 + xScale.bandwidth() - 70}, -4)`}>
<!-- Color box -->
<rect
style="border-radius:10px;"
width="16"
height="16"
rx="4"
ry="4"
fill={colors[i]} />
<!-- Category text -->
<text
class="fill-gray-300"
x="20"
y="10"
font-size="14px"
alignment-baseline="middle">{category}</text>
</g>
{/each}
</g>
Axis Component
Abstracting the Y-axis logic into a reusable component makes the main chart code cleaner and allows reuse in other visualizations.
<!-- AxisLeftV5 -->
<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(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}
X and Y Axis Labels
If you want to add descriptive labels to your axes, use the XAxisLabels
and YAxisLabels
components. These components allow flexible positioning and styling of axis labels.
The XAxisLabel
component positions the label horizontally at the bottom of the chart. You can align the label to the left, center, or right of the axis using the position prop.
<!-- XAxisLabel.svelte -->
<script>
let {
width,
height,
margin,
label,
xoffset = 0,
yoffset = 0,
position = 'center', // 'left', 'center', 'right'
textanchor = 'middle', // Default anchor for centered text
fillColor = 'white'
} = $props();
// Calculate x position for the label
const calculateXPosition = () => {
if (position === 'left') return margin.left + xoffset;
if (position === 'right') return margin.left + width + xoffset;
return width / 2 - margin.left / 2 + xoffset; // Default: center
};
</script>
<text
fill={fillColor}
x={calculateXPosition()}
y={height - margin.bottom + 40 + yoffset}
text-anchor={textanchor}>
{label}
</text>
The YAxisLabel
component positions the label vertically along the left side of the chart. Use the position prop to align the label at the top, center, or bottom of the axis.
<!-- YAxisLabel.svelte -->
<script>
let {
height,
margin,
label,
xoffset = 0,
yoffset = 0,
position = 'center', // 'top', 'center', 'bottom'
textanchor = 'end', // Default anchor for vertical text
fillColor = 'white'
} = $props();
// Calculate y position for the label
const calculateYPosition = () => {
if (position === 'top') return -margin.top + 40 + yoffset;
if (position === 'bottom') return height + margin.top + 40 + yoffset;
return height / 2 + margin.top / 2 + 40 + yoffset; // Default: center
};
</script>
<text
fill={fillColor}
x={margin.left + xoffset}
y={calculateYPosition()}
text-anchor={textanchor}>
{label}
</text>
In this tutorial, we learned how to create a stacked bar chart using Svelte 5 and D3, with legends, labels, and responsive design. Experiment with different datasets and styles to create visualizations tailored to your needs!
Join our Discord to share your charts, ask questions, or collaborate with fellow developers!