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

050100150200250SeasonsTotal Sales ↑WinterSpringSummerFallFlightTrainShipHotelBus

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

050100150200250SeasonsTotal Sales ↑WinterSpringSummerFallFlightTrainShipHotelBus
  1. Each Layer:

    • Represents a single travel type (Flight, Train, Ship, Hotel, Bus).
    • Contains arrays for each time period (Winter, Spring, Summer, Fall).
  2. 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!