Charts & Stats chartsvgzero-dependencyareastackedcompositiontime-series
Stacked Area Chart
Stacked or 100% normalised area chart showing how each layer contributes to a changing total.
Live Preview
Sessions per month, stacked
- Organic
- Paid
- Referral
View data
| Period | Organic | Paid | Referral | Total |
|---|---|---|---|---|
| Jan | 320 | 180 | 90 | 590 |
| Feb | 340 | 210 | 110 | 660 |
| Mar | 390 | 200 | 120 | 710 |
| Apr | 420 | 240 | 130 | 790 |
| May | 460 | 260 | 125 | 845 |
| Jun | 480 | 250 | 150 | 880 |
| Jul | 530 | 270 | 170 | 970 |
| Aug | 560 | 300 | 160 | 1k |
| Sep | 610 | 290 | 190 | 1.1k |
Usage
Copy and paste this code into your Astro page:
astro
---
import { StackedAreaChart } from 'astro-component-kit/charts';
---
<StackedAreaChart
categories={['Jan', 'Feb', 'Mar', 'Apr']}
series={[
{ name: 'Organic', data: [320, 340, 390, 420] },
{ name: 'Paid', data: [180, 210, 200, 240] },
]}
normalized={false}
/> ---
import { StackedAreaChart } from 'astro-component-kit/charts';
---
<StackedAreaChart
categories={['Jan', 'Feb', 'Mar', 'Apr']}
series={[
{ name: 'Organic', data: [320, 340, 390, 420] },
{ name: 'Paid', data: [180, 210, 200, 240] },
]}
normalized={false}
/> Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/lib/StackedAreaChart.astro:
astro
---
/**
* StackedAreaChart — Stacked (or 100% normalised) SVG area chart for showing how
* parts contribute to a changing total over time.
*
* @param {string[]} categories - X-axis labels.
* @param {Array<{name: string, data: number[]}>} series - Stacked layers, bottom first (max 8).
* @param {boolean} normalized - Show each period as 100% share instead of absolute totals.
* @param {string} curve - Interpolation (smooth|linear|step).
* @param {string} prefix - Value prefix.
* @param {string} suffix - Value suffix.
* @param {number} height - SVG height in viewBox units (width is 640).
* @param {string} variant - Card style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
*/
import ChartFrame from './ChartFrame.astro';
import { colorAt, scaleLinear, niceTicks, bandPath, curvePath, formatValue, type Point } from './chart-utils';
interface Props {
title?: string;
subtitle?: string;
categories?: string[];
series?: Array<{ name: string; data: number[] }>;
normalized?: boolean;
curve?: 'smooth' | 'linear' | 'step';
prefix?: string;
suffix?: string;
height?: number;
colors?: string[];
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'Traffic by channel',
subtitle = 'Sessions per month, stacked',
categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'],
series = [
{ name: 'Organic', data: [320, 340, 390, 420, 460, 480, 530, 560, 610] },
{ name: 'Paid', data: [180, 210, 200, 240, 260, 250, 270, 300, 290] },
{ name: 'Referral', data: [90, 110, 120, 130, 125, 150, 170, 160, 190] },
],
normalized = false,
curve = 'smooth',
prefix = '',
suffix = '',
height = 280,
colors,
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const W = 640;
const H = height;
const m = { t: 12, r: 16, b: 28, l: 44 };
const n = categories.length;
const totals = categories.map((_, i) => series.reduce((a, s) => a + (s.data[i] ?? 0), 0));
const val = (si: number, i: number) => {
const v = series[si].data[i] ?? 0;
return normalized ? (v / (totals[i] || 1)) * 100 : v;
};
const ticks = normalized ? [0, 25, 50, 75, 100] : niceTicks(0, Math.max(...totals), 4);
const y = scaleLinear(0, ticks[ticks.length - 1], H - m.b, m.t);
const x = (i: number) => m.l + (n <= 1 ? 0 : (i / (n - 1)) * (W - m.l - m.r));
const step = n > 1 ? (W - m.l - m.r) / (n - 1) : W;
const running = categories.map(() => 0);
const layers = series.slice(0, 8).map((s, si) => {
const lower: Point[] = categories.map((_, i) => [x(i), y(running[i])]);
categories.forEach((_, i) => { running[i] += val(si, i); });
const upper: Point[] = categories.map((_, i) => [x(i), y(running[i])]);
return { name: s.name, color: colorAt(si, colors), d: bandPath(upper, lower, curve), top: curvePath(upper, curve) };
});
const fmt = (v: number) => (normalized ? `${v.toFixed(0)}%` : formatValue(v, prefix, suffix));
const fmtRaw = (v: number) => formatValue(v, prefix, suffix);
---
<ChartFrame
title={title}
subtitle={subtitle}
variant={variant}
size={size}
className={className}
legend={layers.map((l) => ({ label: l.name, color: l.color }))}
table={{
headers: ['Period', ...series.map((s) => s.name), 'Total'],
rows: categories.map((c, i) => [c, ...series.map((s) => fmtRaw(s.data[i])), fmtRaw(totals[i])]),
}}
>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: stacked area chart`}>
{ticks.map((t) => (
<g>
<line class="cf-grid" x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} />
<text class="cf-label" x={m.l - 8} y={y(t) + 4} text-anchor="end">{fmt(t)}</text>
</g>
))}
{categories.map((c, i) => <text class="cf-label" x={x(i)} y={H - 8} text-anchor="middle">{c}</text>)}
{layers.map((l) => (
<g aria-hidden="true">
<path d={l.d} fill={l.color} fill-opacity="0.32" />
<path d={l.top} fill="none" stroke={l.color} stroke-width="2" />
</g>
))}
{categories.map((c, i) => (
<g class="cf-hit-group">
<line class="cf-cross" x1={x(i)} x2={x(i)} y1={m.t} y2={H - m.b} />
<rect
class="cf-hit"
x={x(i) - step / 2}
y={m.t}
width={step}
height={H - m.t - m.b}
tabindex="0"
role="img"
aria-label={`${c}: ${series.map((s) => `${s.name} ${fmtRaw(s.data[i])}`).join(', ')}; total ${fmtRaw(totals[i])}`}
data-tip-title={`${c} · ${fmtRaw(totals[i])}`}
data-tip={[...layers].reverse().map((l, ri) => {
const si = layers.length - 1 - ri;
return `${l.color}::${l.name}: ${fmtRaw(series[si].data[i])}${normalized ? ` (${val(si, i).toFixed(0)}%)` : ''}`;
}).join('|')}
/>
</g>
))}
</svg>
</ChartFrame> ---
/**
* StackedAreaChart — Stacked (or 100% normalised) SVG area chart for showing how
* parts contribute to a changing total over time.
*
* @param {string[]} categories - X-axis labels.
* @param {Array<{name: string, data: number[]}>} series - Stacked layers, bottom first (max 8).
* @param {boolean} normalized - Show each period as 100% share instead of absolute totals.
* @param {string} curve - Interpolation (smooth|linear|step).
* @param {string} prefix - Value prefix.
* @param {string} suffix - Value suffix.
* @param {number} height - SVG height in viewBox units (width is 640).
* @param {string} variant - Card style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
*/
import ChartFrame from './ChartFrame.astro';
import { colorAt, scaleLinear, niceTicks, bandPath, curvePath, formatValue, type Point } from './chart-utils';
interface Props {
title?: string;
subtitle?: string;
categories?: string[];
series?: Array<{ name: string; data: number[] }>;
normalized?: boolean;
curve?: 'smooth' | 'linear' | 'step';
prefix?: string;
suffix?: string;
height?: number;
colors?: string[];
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'Traffic by channel',
subtitle = 'Sessions per month, stacked',
categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'],
series = [
{ name: 'Organic', data: [320, 340, 390, 420, 460, 480, 530, 560, 610] },
{ name: 'Paid', data: [180, 210, 200, 240, 260, 250, 270, 300, 290] },
{ name: 'Referral', data: [90, 110, 120, 130, 125, 150, 170, 160, 190] },
],
normalized = false,
curve = 'smooth',
prefix = '',
suffix = '',
height = 280,
colors,
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const W = 640;
const H = height;
const m = { t: 12, r: 16, b: 28, l: 44 };
const n = categories.length;
const totals = categories.map((_, i) => series.reduce((a, s) => a + (s.data[i] ?? 0), 0));
const val = (si: number, i: number) => {
const v = series[si].data[i] ?? 0;
return normalized ? (v / (totals[i] || 1)) * 100 : v;
};
const ticks = normalized ? [0, 25, 50, 75, 100] : niceTicks(0, Math.max(...totals), 4);
const y = scaleLinear(0, ticks[ticks.length - 1], H - m.b, m.t);
const x = (i: number) => m.l + (n <= 1 ? 0 : (i / (n - 1)) * (W - m.l - m.r));
const step = n > 1 ? (W - m.l - m.r) / (n - 1) : W;
const running = categories.map(() => 0);
const layers = series.slice(0, 8).map((s, si) => {
const lower: Point[] = categories.map((_, i) => [x(i), y(running[i])]);
categories.forEach((_, i) => { running[i] += val(si, i); });
const upper: Point[] = categories.map((_, i) => [x(i), y(running[i])]);
return { name: s.name, color: colorAt(si, colors), d: bandPath(upper, lower, curve), top: curvePath(upper, curve) };
});
const fmt = (v: number) => (normalized ? `${v.toFixed(0)}%` : formatValue(v, prefix, suffix));
const fmtRaw = (v: number) => formatValue(v, prefix, suffix);
---
<ChartFrame
title={title}
subtitle={subtitle}
variant={variant}
size={size}
className={className}
legend={layers.map((l) => ({ label: l.name, color: l.color }))}
table={{
headers: ['Period', ...series.map((s) => s.name), 'Total'],
rows: categories.map((c, i) => [c, ...series.map((s) => fmtRaw(s.data[i])), fmtRaw(totals[i])]),
}}
>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: stacked area chart`}>
{ticks.map((t) => (
<g>
<line class="cf-grid" x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} />
<text class="cf-label" x={m.l - 8} y={y(t) + 4} text-anchor="end">{fmt(t)}</text>
</g>
))}
{categories.map((c, i) => <text class="cf-label" x={x(i)} y={H - 8} text-anchor="middle">{c}</text>)}
{layers.map((l) => (
<g aria-hidden="true">
<path d={l.d} fill={l.color} fill-opacity="0.32" />
<path d={l.top} fill="none" stroke={l.color} stroke-width="2" />
</g>
))}
{categories.map((c, i) => (
<g class="cf-hit-group">
<line class="cf-cross" x1={x(i)} x2={x(i)} y1={m.t} y2={H - m.b} />
<rect
class="cf-hit"
x={x(i) - step / 2}
y={m.t}
width={step}
height={H - m.t - m.b}
tabindex="0"
role="img"
aria-label={`${c}: ${series.map((s) => `${s.name} ${fmtRaw(s.data[i])}`).join(', ')}; total ${fmtRaw(totals[i])}`}
data-tip-title={`${c} · ${fmtRaw(totals[i])}`}
data-tip={[...layers].reverse().map((l, ri) => {
const si = layers.length - 1 - ri;
return `${l.color}::${l.name}: ${fmtRaw(series[si].data[i])}${normalized ? ` (${val(si, i).toFixed(0)}%)` : ''}`;
}).join('|')}
/>
</g>
))}
</svg>
</ChartFrame>
Quick Info
- Category
- Charts & Stats
- Filename
StackedAreaChart.astro- Dependencies
- apexcharts (peer)
- Tags
- chartsvgzero-dependencyareastackedcompositiontime-series