Sextant
← Playground

Streamgraph

free

A 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-streamgraph
State
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.

Revenue by contract type — value by category over time
PeriodEnterpriseMid-marketSMBSelf-serve
Jun1.7k920680420
Jul1.7k960710450
Aug1.7k1k750480
Sep1.7k1.1k800520
Oct1.7k1.1k860540
Nov1.7k1.2k920480
Dec1.7k1.3k980410
Jan1.8k1.3k1.1k480
Feb1.8k1.4k1.1k560
Mar1.8k1.5k1.2k640
Apr1.8k1.6k1.3k720
May1.8k1.6k1.4k800

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>
  );
}