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:
- Main axis lines: Created using
<line>
elements - Tick marks: Small lines perpendicular to the axis
- Labels: Text elements showing values or dates
- 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:
- Starts with a base translation to account for the left margin
- 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
- Takes the available width (
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 datascaleTime()
creates a time-based scale for dates- The scale maps dates to x-coordinates between
margin.left
andwidth - 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:
Add Interactivity:
- Implement tooltips to show exact values
- Add zoom and pan capabilities
- Create click events for detailed views
Customize the Visual Design:
- Experiment with different color schemes
- Add axis labels and annotations
- Implement different curve types
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!