Stacked composition Only the bottom band has a straight baseline, so only the bottom band is comparable across columns. Every band above it is being read off a moving floor, which is why a stack answers share and not trend. npx shadcn@latest add toggle-group npm i recharts Tokens this needs: --card, --muted, --muted-foreground, --border, --chart-1, --chart-2 ──────────────────────────────────────────────────────────────────────── // StackedArea.tsx ──────────────────────────────────────────────────────────────────────── import { Area, AreaChart, Tooltip, XAxis } from "recharts"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; /** * Stacking is not symmetric. The bottom band sits on a flat baseline and is * exactly readable; the outline is the total and is exact too; every band * between them is read off a moving floor and is approximate. So the order * of `series` is a decision: the first one is the bottom, and it should be * the series that most needs scrutiny, never whatever the query returned. * * `mode` is a closed pair because the two answer different questions. Normal * answers "how much and of what". 100% answers "what share" and discards the * total, so in that mode the total is printed beneath the chart instead. */ export type Mode = "normal" | "percent"; export type Series = { key: string; label: string; values: number[]; /** Assigned by key by the caller, so the same series is the same colour on every panel. */ color: string; }; const TICK = { fontSize: 10, fill: "hsl(var(--muted-foreground))" }; export function StackedArea({ labels, series, mode, unit, onModeChange, width = 400, height = 190 }: { labels: string[]; /** Bottom first. */ series: Series[]; mode: Mode; unit: string; onModeChange?: (mode: Mode) => void; width?: number; height?: number; }) { const data = labels.map((label, i) => Object.fromEntries([["label", label], ...series.map((s) => [s.key, s.values[i]])])); const totals = labels.map((_, i) => series.reduce((n, s) => n + s.values[i], 0)); const first = totals[0], last = totals[totals.length - 1]; const change = Math.round(((last - first) / first) * 100); return (
total {first} → {last} {unit}, {change === 0 ? "unchanged" : `${change > 0 ? "up" : "down"} ${Math.abs(change)}%`}
)} {onModeChange && (