Streamgraph
freeA flowing, centered streamgraph of composition over time, with smooth bands, a left-to-right reveal, and an auto-generated narrative naming the largest and fastest-growing streams.
Live preview
npx shadcn add @sextant/chart-streamgraphState
Size
Mode
Revenue by contract type
5.6k
latest total · 4 streams
- Enterprise
- Mid-market
- SMB
- Self-serve
Total grew +51% to 5.6k across 4 streams — Enterprise is the largest, Mid-market the fastest-growing.
| Period | Enterprise | Mid-market | SMB | Self-serve |
|---|---|---|---|---|
| Jun | 1.7k | 920 | 680 | 420 |
| Jul | 1.7k | 960 | 710 | 450 |
| Aug | 1.7k | 1k | 750 | 480 |
| Sep | 1.7k | 1.1k | 800 | 520 |
| Oct | 1.7k | 1.1k | 860 | 540 |
| Nov | 1.7k | 1.2k | 920 | 480 |
| Dec | 1.7k | 1.3k | 980 | 410 |
| Jan | 1.8k | 1.3k | 1.1k | 480 |
| Feb | 1.8k | 1.4k | 1.1k | 560 |
| Mar | 1.8k | 1.5k | 1.2k | 640 |
| Apr | 1.8k | 1.6k | 1.3k | 720 |
| May | 1.8k | 1.6k | 1.4k | 800 |
Acme Analytics · updated monthly from CRM
Source — this is the whole widget
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import {
ChartNarrative,
resolveNarrative,
type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
import { ChartFigure, type ChartTable } from "@/registry/default/chart-a11y/chart-a11y";
import { ChartErrorBoundary } from "@/registry/default/sextant-contracts/chart-error-boundary";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import { safeData } from "@/registry/default/sextant-contracts/contracts";
import { useReducedMotion } from "@/registry/default/widget-kit/motion";
import { SparkTile } from "@/registry/default/widget-kit/spark-tile";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { useSizePlan } from "@/registry/default/widget-kit/use-chart-size";
import type { SizeProp } from "@/registry/default/widget-kit/chart-size";
import type { SizePlan } from "@/registry/default/widget-kit/size-plan";
import type {
ChartClassNames,
SeriesConfig,
} from "@/registry/default/widget-kit/contract";
import { streamData, streamDataSchema, type StreamDatum } from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export { streamPointSchema, streamDataSchema, type StreamDatum } from "./data";
import { streamLayout, streamStats } from "./stream";
const COLOR_PRESETS = [
{ name: "Maroon (brand)", value: "var(--chart-1)" },
{ name: "Sage", value: "var(--chart-positive)" },
{ name: "Honey", value: "var(--chart-warning)" },
{ name: "Ink", value: "var(--chart-2)" },
];
const PALETTE = [
"var(--chart-1)",
"var(--chart-positive)",
"var(--chart-warning)",
"var(--chart-3)",
"var(--chart-5)",
"var(--chart-2)",
];
const STREAM_PLAN: SizePlan = {
lg: { shape: "full", legend: "full" },
md: { shape: "full", legend: "inline" },
sm: { shape: "spark" },
mobile: { shape: "full", legend: "inline", stack: true },
};
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
/** ISO → "MMM" without date-fns; passes non-dates through unharmed. */
function isoMonth(iso: string): string {
const m = /^\d{4}-(\d{2})/.exec(iso);
return m ? (MONTHS[Number(m[1]) - 1] ?? iso) : iso;
}
function fmtNum(v: number): string {
const abs = Math.abs(v);
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (abs >= 1000) return `${(v / 1000).toFixed(1).replace(/\.0$/, "")}k`;
return Math.round(v).toLocaleString();
}
/** Uniform band tint override; default is a palette across categories. */
export type ChartStreamgraphSeries = { band?: SeriesConfig };
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
/** Categories + per-point values. Validated at runtime; a bad shape shows the error state. */
data?: StreamDatum;
// ── Card chrome ───────────────────────────────────────────────────────────
/** Card title (also the export filename). */
label?: string;
attribution?: string;
/** Shorthand for `classNames.root` — width, ring, shadow, margins, etc. */
className?: string;
/** Targeted class overrides per region (root/header/body/narrative/footnote). */
classNames?: ChartClassNames;
/** Force a size tier, or "auto" (default) to adapt to the card's own width. */
size?: SizeProp;
// ── Customize ───────────────────────────────────────────────────────────
/** Uniformly recolor every band; default is a per-category palette. */
series?: ChartStreamgraphSeries;
/** Override value formatting (hero, labels, tooltip). Default is compact (k/M). */
valueFormatter?: (n: number) => string;
/** Replace, hide (`false`), or fully render the one-line narrative. */
narrative?: NarrativeProp;
};
export function ChartStreamgraph({
data = streamData,
label,
attribution,
className,
classNames,
size = "auto",
series,
valueFormatter,
narrative,
}: Props) {
const studio = useStudio();
const title = studio.text("title", label ?? "Revenue by contract type", {
label: "Chart title",
});
const accent = studio.color("accent", series?.band?.color ?? "var(--chart-1)", {
label: "Accent color",
presets: COLOR_PRESETS,
});
const attributionText = studio.text(
"attribution",
attribution ?? "Acme Analytics · updated monthly from CRM",
{ label: "Attribution" },
);
const fmt = valueFormatter ?? fmtNum;
const parsed = React.useMemo(() => safeData(streamDataSchema, data), [data]);
const safe = parsed.ok ? parsed.data : null;
const stats = React.useMemo(() => (safe ? streamStats(safe) : null), [safe]);
if (!parsed.ok) {
return (
<ChartStates status="error" shape="area" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
if (!safe || !stats || safe.points.length < 2) {
return (
<ChartStates
status="empty"
shape="area"
empty={{
title: "Not enough history",
description: "At least two points are needed to flow the streams.",
}}
>
{null}
</ChartStates>
);
}
const colorOf = (i: number) => series?.band?.color ?? PALETTE[i % PALETTE.length];
const dir = stats.deltaPct >= 0 ? "grew" : "shrank";
const autoNarrative =
`Total ${dir} ${stats.deltaPct >= 0 ? "+" : ""}${(stats.deltaPct * 100).toFixed(0)}% to ${fmt(stats.lastTotal)} across ${safe.categories.length} streams` +
(stats.largest ? ` — ${stats.largest.label} is the largest` : "") +
(stats.fastest && stats.fastest.label !== stats.largest?.label
? `, ${stats.fastest.label} the fastest-growing.`
: ".");
const chipText = `${stats.deltaPct >= 0 ? "▲" : "▼"} ${Math.abs(stats.deltaPct * 100).toFixed(0)}% · ${fmt(stats.lastTotal)}`;
return (
<ChartCard.Root
size={size}
className={cn(className, classNames?.root)}
exportable
exportTitle={title}
>
<StreamInner
data={safe}
title={title}
heroValue={fmt(stats.lastTotal)}
chipText={chipText}
narrative={narrative}
autoNarrative={autoNarrative}
accent={accent}
bandColor={series?.band?.color}
colorOf={colorOf}
attributionText={attributionText}
fmt={fmt}
classNames={classNames}
/>
</ChartCard.Root>
);
}
type InnerProps = {
data: StreamDatum;
title: string;
heroValue: string;
chipText: string;
narrative: NarrativeProp | undefined;
autoNarrative: string;
accent: string;
bandColor?: string;
colorOf: (i: number) => string;
attributionText: string;
fmt: (n: number) => string;
classNames?: ChartClassNames;
};
function StreamInner({
data,
title,
heroValue,
chipText,
narrative,
autoNarrative,
accent,
bandColor,
colorOf,
attributionText,
fmt,
classNames,
}: InnerProps) {
const spec = useSizePlan(STREAM_PLAN);
if (spec.shape === "spark") {
return (
<SparkTile
label={title}
value={heroValue}
chip={
narrative === false ? undefined : (
<ChartNarrative
text={typeof narrative === "string" ? narrative : chipText}
variant="chip"
className={classNames?.narrative}
/>
)
}
spark={<StreamSpark data={data} accent={accent} />}
/>
);
}
const table: ChartTable = {
caption: `${title} — value by category over time`,
columns: ["Period", ...data.categories],
rows: data.points.map((p) => [isoMonth(p.date), ...p.values.map((v) => fmt(v))]),
};
const legendItems = data.categories.map((l, i) => ({ label: l, color: colorOf(i) }));
return (
<>
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
<ChartCard.HeroNumber>{heroValue}</ChartCard.HeroNumber>
<p className="tabular text-sm text-muted-foreground">
latest total · {data.categories.length} streams
</p>
</ChartCard.HeaderMain>
{(spec.legend === "full" || spec.legend === "inline") && (
<ChartCard.Legend items={legendItems} />
)}
</ChartCard.Header>
{resolveNarrative(narrative ?? autoNarrative, undefined, {
className: classNames?.narrative,
})}
<ChartCard.Body className={classNames?.body}>
<ChartErrorBoundary minHeight={240}>
<ChartFigure label={autoNarrative} table={table}>
<ChartStreamgraphPlot
data={data}
bandColor={bandColor}
colorOf={colorOf}
fmt={fmt}
height={spec.shape === "compact" ? 220 : 300}
/>
</ChartFigure>
</ChartErrorBoundary>
</ChartCard.Body>
<StudioTarget fieldKey="attribution" variant="inline">
<ChartCard.Footnote className={classNames?.footnote}>
{attributionText}
</ChartCard.Footnote>
</StudioTarget>
</>
);
}
type PlotProps = {
/** Categories + per-point values — the stacked silhouette is derived internally. */
data: StreamDatum;
/** Uniform band color; default tints each band by its category. */
bandColor?: string;
/** Resolve a category index to a color (default palette). */
colorOf?: (i: number) => string;
fmt?: (n: number) => string;
/** Drawing height in px (the SVG scales to its container's width). */
height?: number;
};
/**
* Just the river — smooth, centered category bands with hover-to-isolate and a
* left-to-right reveal. No card chrome, narrative, or states. Pure SVG.
*/
export function ChartStreamgraphPlot({
data,
bandColor,
colorOf = (i) => PALETTE[i % PALETTE.length],
fmt = (n) => String(n),
height = 300,
}: PlotProps) {
const uid = React.useId().replace(/:/g, "");
const reduced = useReducedMotion();
const [hover, setHover] = React.useState<number | null>(null);
const W = 720;
const AXIS = 18;
const plotH = height - AXIS;
const layout = React.useMemo(
() => streamLayout(data, { width: W, height: plotH }),
[data, plotH],
);
// Sparse month labels: first, last, and a few between.
const n = data.points.length;
const tickEvery = Math.max(1, Math.round(n / 6));
return (
<>
{!reduced && (
<style
href="sx-stream-kf"
precedence="default"
>{`@keyframes sx-stream-reveal{from{transform:scaleX(0)}to{transform:scaleX(1)}}`}</style>
)}
<svg
viewBox={`0 0 ${W} ${height}`}
width="100%"
height="auto"
role="presentation"
style={{ display: "block" }}
>
{!reduced && (
<clipPath id={`sg-${uid}-clip`}>
<rect
x={0}
y={0}
width={W}
height={plotH}
style={{
transformOrigin: "left",
animation: `sx-stream-reveal 720ms cubic-bezier(0.25,1,0.5,1) both`,
}}
/>
</clipPath>
)}
<g clipPath={reduced ? undefined : `url(#sg-${uid}-clip)`}>
{layout.bands.map((b) => {
const dim = hover !== null && hover !== b.index;
return (
<path
key={b.index}
d={b.path}
fill={bandColor ?? colorOf(b.index)}
fillOpacity={dim ? 0.2 : 0.92}
stroke="var(--card)"
strokeWidth={0.75}
onMouseEnter={() => setHover(b.index)}
onMouseLeave={() => setHover(null)}
style={{ transition: "fill-opacity 200ms ease", cursor: "pointer" }}
>
<title>{`${b.label}: ${fmt(b.mean)} avg`}</title>
</path>
);
})}
</g>
{/* x-axis month labels */}
<g>
{data.points.map((p, i) =>
i % tickEvery === 0 || i === n - 1 ? (
<text
key={i}
x={layout.xs[i]}
y={height - 4}
textAnchor={i === 0 ? "start" : i === n - 1 ? "end" : "middle"}
className="smallcaps"
style={{ fontSize: 9, fill: "var(--muted-foreground)" }}
>
{isoMonth(p.date)}
</text>
) : null,
)}
</g>
</svg>
</>
);
}
/** A glance glyph for the Small tile: the total as a small centered silhouette. */
function StreamSpark({ data, accent }: { data: StreamDatum; accent: string }) {
const totals = data.points.map((p) =>
p.values.reduce((a, v) => a + Math.max(0, v), 0),
);
const max = Math.max(1, ...totals);
const n = totals.length;
const top = totals.map(
(t, i) => [(i / (n - 1)) * 96, 24 - (t / max) * 11] as [number, number],
);
const bottom = totals.map(
(t, i) => [(i / (n - 1)) * 96, 24 + (t / max) * 11] as [number, number],
);
const seg = (pts: [number, number][]) =>
pts.map((p, i) => (i === 0 ? `${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(" ");
const path = `M${seg(top)} L${seg([...bottom].reverse())} Z`;
return (
<svg viewBox="0 0 96 48" width="100%" height="100%" role="presentation">
<path d={path} fill={accent} fillOpacity={0.7} />
</svg>
);
}