Creating an Interactive Area Chart with Gradient Fills

Area charts are powerful tools for visualizing quantitative data over time, especially when you want to emphasize the magnitude of change between different categories. They excel at showing trends and making comparisons, making them perfect for displaying metrics like market share, revenue distribution, or usage patterns.

In this tutorial, we’ll build an interactive area chart that compares payment methods over time. We’ll use gradient fills to create depth and implement smooth hover transitions for better user interaction. The final result will be a professional-looking visualization that’s both informative and engaging.

Common Use Cases

  • Market share analysis
  • Financial data visualization
  • Traffic or usage patterns
  • Environmental data trends
  • Population demographics

You can download the data here

Purchases by payment method


What You’ll Learn

  • Setting up D3 modules for creating an area chart
  • Using D3’s area generator for filled shapes
  • Creating SVG gradients for area fills
  • Implementing smooth hover transitions
  • Making the chart responsive
  • Handling data loading and error states

Important Considerations

Before we dive in, let’s address some common challenges:

Data Loading: We’ll implement basic error handling for CSV loading:

  • Use promise chaining for data loading
  • Log any errors that occur during fetch
  • Handle the case when data is unavailable

Browser Compatibility:

  • Hover effects will use the modern CSS :has() selector
  • We’ll provide fallback styles for older browsers
  • Alternative approaches will be discussed

Full Code Preview

<script>
  import { scaleLinear, scaleTime } from 'd3-scale';
  import { extent, max } from 'd3-array';
  import { area, curveLinear, line } from 'd3-shape';
  import { csv } from 'd3-fetch';
  import { onMount } from 'svelte';

  // Data and dimensions
  let data = $state([]);
  let width = $state(475); // width will be set by the clientWidth
  const height = 250;
  const margin = { top: 0, right: 20, bottom: 56, left: 30 };

  // Line generator (reactive with data and scales)
  let xScale = $derived(
    scaleTime()
      .domain(extent(data, (d) => new Date(d.date)))
      .range([margin.left, width - margin.right])
  );

  let yScale = $derived(
    scaleLinear()
      .domain([0, max(data, (d) => Math.max(+d.type1, +d.type2))])
      .range([height - margin.bottom, margin.top])
  );

  // Generalized line and area generator functions
  function generateLinePath(data, yField) {
    return line()
      .x((d) => xScale(new Date(d.date)))
      .y((d) => yScale(+d[yField]))
      .curve(curveLinear)(data);
  }

  function generateAreaPath(data, yField) {
    return area()
      .x((d) => xScale(new Date(d.date)))
      .y0(height - margin.bottom)
      .y1((d) => yScale(+d[yField]))
      .curve(curveLinear)(data);
  }

  // Load data on mount
  onMount(() => {
    csv('/data/payments.csv')
      .then((csvData) => (data = csvData))
      .catch((error) => console.error('Failed to load CSV data:', error));
  });

  const settings = [
    { category: 'Bank', color: 'rgba(141, 187, 251, 1)' }, // Blue
    { category: 'Amazon Pay', color: 'rgb(251, 141, 220)' } // Purple
  ];
</script>

<div
  class="relative min-w-full max-w-md rounded-xl p-6"
  bind:clientWidth={width}>
  <div class="flex w-full items-center justify-between pr-8">
    <h2 class="font-semibold font-gray-600">Purchases by payment method</h2>
  </div>
  {#if data.length && width && xScale && yScale}
    <svg width={width - margin.left} {height}>
      <defs>
        <!-- Purple gradient -->
        <linearGradient id="lineShade1" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="rgba(251, 141, 220, 0.6)" />
          <stop offset="100%" stop-color="rgba(251, 141, 220, 0)" />
        </linearGradient>

        <!-- Blue gradient -->
        <linearGradient id="lineShade2" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="rgba(141, 187, 251, 0.6)" />
          <stop offset="100%" stop-color="rgba(141, 187, 251, 0)" />
        </linearGradient>
      </defs>

      <!-- X Axis -->
      <g transform="translate(0,{height - margin.bottom})">
        <line
          stroke="currentColor"
          x1={margin.left - 6}
          x2={width - margin.right} />
        {#each xScale.ticks() as tick}
          {#if tick !== 0}
            <line
              stroke="currentColor"
              x1={xScale(tick)}
              x2={xScale(tick)}
              y1={0}
              y2={6} />
          {/if}
        {/each}
        {#each xScale.ticks(3) as tick}
          <text
            font-size="12px"
            class="stroke-gray-400"
            text-anchor="middle"
            x={xScale(tick)}
            y={16}>
            {tick.toLocaleString('default', { month: 'short' })}
          </text>
        {/each}
      </g>

      <!-- Y Axis -->
      <g transform="translate({margin.left},0)">
        <line
          stroke="currentColor"
          y1={yScale(0)}
          y2={yScale(yScale.domain()[1])} />
        {#each yScale.ticks(2) 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>
      <text
        x={0}
        y={yScale(yScale.domain()[1] + 10)}
        class="fill-gray-400"
        font-size="12px"
        text-anchor="start">
        Million ($) ↑
      </text>

      <!-- Area Paths -->
      <path
        class="area-path area-path-1"
        d={generateAreaPath(data, 'type1')}
        fill="url(#lineShade1)" />
      <path
        class="area-path area-path-2"
        d={generateAreaPath(data, 'type2')}
        fill="url(#lineShade2)" />

      <!-- Category Labels with Color Indicators -->
      <g transform={`translate(${margin.left}, ${height - 20})`}>
        {#each settings as d, i}
          <g
            transform={`translate(${
              ((width - margin.left - margin.right) * (i + 1)) /
              (settings.length + 1)
            }, -4)`}>
            <!-- Color box -->
            <rect
              style="border-radius:10px;"
              width="16"
              height="16"
              rx="4"
              ry="4"
              fill={d.color} />
            <!-- Category text -->
            <text
              class="fill-gray-400"
              x="20"
              y="10"
              font-size="10px"
              alignment-baseline="middle">{d.category}</text>
          </g>
        {/each}
      </g>
    </svg>
  {/if}
</div>

<style>
  .area-path {
    opacity: 0.9;
    transition-property: opacity;
    transition-duration: 200ms;
    transition-timing-function: ease-in-out;
  }

  .area-path:hover {
    opacity: 1;
  }

  /* When any area-path is hovered, fade out all following siblings */
  .area-path:hover ~ .area-path {
    opacity: 0.2;
  }

  /* When any area-path is hovered, fade out all previous siblings */
  .area-path:has(~ .area-path:hover) {
    opacity: 0.2;
  }

  .area-path-1 {
    fill: url(#lineShade1);
  }

  .area-path-2 {
    fill: url(#lineShade2);
  }
</style>

Implementing Axes

The chart uses SVG groups (<g>) to create and position the axes:

<!-- X Axis -->
<g transform="translate(0,{height - margin.bottom})">
  <line stroke="currentColor" x1={margin.left - 6} x2={width - margin.right} />
  {#each xScale.ticks() as tick}
    {#if tick !== 0}
      <line
        stroke="currentColor"
        x1={xScale(tick)}
        x2={xScale(tick)}
        y1={0}
        y2={6} />
    {/if}
  {/each}
  {#each xScale.ticks(3) as tick}
    <text
      font-size="12px"
      class="stroke-gray-400"
      text-anchor="middle"
      x={xScale(tick)}
      y={16}>
      {tick.toLocaleString('default', { month: 'short' })}
    </text>
  {/each}
</g>

<!-- Y Axis -->
<g transform="translate({margin.left},0)">
  <line stroke="currentColor" y1={yScale(0)} y2={yScale(yScale.domain()[1])} />
  {#each yScale.ticks(2) 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>

<!-- Y Axis Label -->
<text
  x={0}
  y={yScale(yScale.domain()[1] + 10)}
  class="fill-gray-400"
  font-size="12px"
  text-anchor="start">
  Million ($) ↑
</text>

Key points about the axes:

  • X-axis shows months in short format (e.g., “Jan”, “Feb”)
  • Y-axis displays values with tick marks
  • We use SVG transform to position the axes correctly
  • Tick marks are generated using scale’s ticks() method
  • Text labels are positioned and styled for readability

The axes are built using several SVG elements:

  1. Main axis lines: Created using <line> elements
  2. Tick marks: Small lines perpendicular to the axis
  3. Labels: Text elements showing values or dates
  4. Unit label: Shows the measurement unit (Million $)

We use D3’s scale functions to:

  • Generate appropriate tick values (xScale.ticks(), yScale.ticks())
  • Convert data values to pixel positions (xScale(), yScale())
  • Format dates using toLocaleString() for readable month names

Legend Positioning

The legend is positioned at the bottom of the chart using SVG groups and transforms. Let’s break down the positioning logic:

<g transform={`translate(${margin.left}, ${height - 20})`}>
  {#each settings as d, i}
    <g
      transform={`translate(${((width - margin.left - margin.right) * (i + 1)) / (settings.length + 1)}, -4)`}>
      <!-- Legend items -->
    </g>
  {/each}
</g>

This implementation:

  1. Starts with a base translation to account for the left margin
  2. Calculates legend item positions using a formula that:
    • Takes the available width (width - margin.left - margin.right)
    • Divides it into equal segments based on the number of legend items (settings.length + 1)
    • Places each item at position i + 1 to create equal spacing

This approach ensures:

  • Legend items are evenly distributed across the available space
  • Positions automatically adjust when the chart is resized
  • Items maintain equal spacing regardless of their label lengths
  • The legend stays centered within the chart boundaries

Adding Category Labels

The legend at the bottom uses colored rectangles with labels:

<!-- Category Labels with Color Indicators -->
<g transform={`translate(${margin.left}, ${height - 20})`}>
  {#each settings as d, i}
    <g
      transform={`translate(${
        ((width - margin.left - margin.right) * (i + 1)) / (settings.length + 1)
      }, -4)`}>
      <!-- Color box -->
      <rect
        style="border-radius:10px;"
        width="16"
        height="16"
        rx="4"
        ry="4"
        fill={d.color} />
      <!-- Category text -->
      <text
        class="fill-gray-400"
        x="20"
        y="10"
        font-size="10px"
        alignment-baseline="middle">{d.category}</text>
    </g>
  {/each}
</g>

The legend includes:

  • Rounded rectangles showing category colors
  • Category names with consistent styling
  • Proper spacing and alignment
  • Responsive positioning based on chart width

Step-by-Step Guide

Understanding the Scales

The scales in our chart play a crucial role in mapping our data to visual dimensions:

<script>
  let xScale = $derived(
    scaleTime()
      .domain(extent(data, (d) => new Date(d.date)))
      .range([margin.left, width - margin.right])
  );
</script>

Here’s what’s happening:

  • extent() finds the minimum and maximum dates in our data
  • scaleTime() creates a time-based scale for dates
  • The scale maps dates to x-coordinates between margin.left and width - margin.right
let yScale = $derived(
  scaleLinear()
    .domain([0, max(data, (d) => Math.max(+d.type1, +d.type2))])
    .range([height - margin.bottom, margin.top])
);

For the y-scale:

  • We start the domain at 0 for better data representation
  • max() finds the highest value across both payment types
  • The scale maps values to y-coordinates, inverted for SVG coordinates

Generating Area Paths

The area generator function creates the filled shapes:

function generateAreaPath(data, yField) {
  return area()
    .x((d) => xScale(new Date(d.date)))
    .y0(height - margin.bottom)
    .y1((d) => yScale(+d[yField]))
    .curve(curveLinear)(data);
}

The .y0() method sets the baseline (bottom) of the area, while .y1() defines the top line.

Creating Gradient Fills

We define gradients in the SVG’s <defs> section:

<defs>
  <linearGradient id="lineShade1" x1="0" y1="0" x2="0" y2="1">
    <stop offset="0%" stop-color="rgba(251, 141, 220, 0.6)" />
    <stop offset="100%" stop-color="rgba(251, 141, 220, 0)" />
  </linearGradient>
</defs>

The gradient transitions from a semi-transparent color at the top to fully transparent at the bottom.

Implementing Hover Effects

We use CSS transitions for smooth hover interactions:

<style>
  .area-path {
    opacity: 0.9;
    transition-property: opacity;
    transition-duration: 200ms;
    transition-timing-function: ease-in-out;
  }

  .area-path:hover {
    opacity: 1;
  }

  /* When any area-path is hovered, fade out all following siblings */
  .area-path:hover ~ .area-path {
    opacity: 0.2;
  }

  /* When any area-path is hovered, fade out all previous siblings */
  .area-path:has(~ .area-path:hover) {
    opacity: 0.2;
  }

  .area-path-1 {
    fill: url(#lineShade1);
  }

  .area-path-2 {
    fill: url(#lineShade2);
  }
</style>

This creates a sophisticated hover effect where:

  • Areas smoothly transition their opacity over 200ms
  • The hovered area becomes fully opaque
  • Both previous and following areas fade out to 20% opacity
  • Each area maintains its gradient fill defined by the SVG gradients

Making it Responsive

We use Svelte’s bind:clientWidth to make the chart responsive:

<div class="relative min-w-full max-w-md rounded-xl p-6" bind:clientWidth={width}>

The chart automatically updates when the container width changes.

Data Format

The chart expects CSV data in the following format:

date,price,type1,type2
2024-01-01,0.0,0.0,0.0
2024-02-01,30.0,41.0,30.0
2024-03-01,30.0,60.0,45.0
...

We use d3-fetch to load CSV data and process it for use in our chart. The dates are parsed into JavaScript Date objects, and the numeric values for each type are converted from strings to numbers using the unary plus operator (+). In this example, we’re focusing on visualizing the type1 and type2 columns as area charts.

By default, d3.csv treats all values in the CSV as strings. This is why we use new Date(d.date) for dates and +d[yField] for numeric values in our scale and area generator functions. This conversion ensures proper data handling for both the axes and area shapes.

Conclusion

In this tutorial we created an interactive area chart with gradient fills and smooth hover transitions.

Further Reading

Next Steps

Now that you’ve built your area chart, here are some ways to enhance it:

  1. Add Interactivity:

    • Implement tooltips to show exact values
    • Add zoom and pan capabilities
    • Create click events for detailed views
  2. Customize the Visual Design:

    • Experiment with different color schemes
    • Add axis labels and annotations
    • Implement different curve types
  3. Improve Accessibility:

    • Add ARIA labels
    • Implement keyboard navigation
    • Provide alternative text descriptions

Join Our Community

Have questions or want to share your visualization? Join our Discord community and connect with other developers!

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