1 GitHub star = 1 new component.

Star the repo
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

Traffic by channel

Sessions per month, stacked

  • Organic
  • Paid
  • Referral
0 500 1k 1.5k JanFebMarAprMayJunJulAugSep
View data
PeriodOrganicPaidReferralTotal
Jan32018090590
Feb340210110660
Mar390200120710
Apr420240130790
May460260125845
Jun480250150880
Jul530270170970
Aug5603001601k
Sep6102901901.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