Live Metric Stream
freeA real-time line that scrolls continuously and ticks in a fresh reading on an interval, with a pulsing live dot. Pauses off-screen and freezes to a clean static frame under reduced-motion.
Live preview
npx shadcn add @sextant/chart-live-streamState
Size
Mode
MRR
4,514,547 USD
paused · 48-reading window
Live · 4,514,547 USD now, +4.0% vs the 48-reading average (4,342,970 USD).
| Reading | Value (USD) |
|---|---|
| t−11 | 4,423,976 |
| t−10 | 4,440,267 |
| t−9 | 4,462,786 |
| t−8 | 4,463,380 |
| t−7 | 4,451,363 |
| t−6 | 4,470,849 |
| t−5 | 4,494,771 |
| t−4 | 4,507,927 |
| t−3 | 4,510,344 |
| t−2 | 4,495,190 |
| t−1 | 4,508,589 |
| t−0 | 4,514,547 |
Source: billing system · updated every 90 seconds
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 { 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 { makeRng, useLiveActive, useRafLoop } from "@/registry/default/widget-kit/live";
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 { liveStream, liveStreamSchema, type LiveStreamDatum } from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export { liveStreamPointSchema, liveStreamSchema, type LiveStreamDatum } from "./data";
import { feedParams, stepValue, streamStats } from "./stream-feed";
const COLOR_PRESETS = [
{ name: "Maroon (brand)", value: "var(--chart-1)" },
{ name: "Sage", value: "var(--chart-positive)" },
{ name: "Ink", value: "var(--chart-2)" },
{ name: "Honey", value: "var(--chart-warning)" },
];
const STREAM_PLAN: SizePlan = {
lg: { shape: "full", legend: "none" },
md: { shape: "full", legend: "none" },
sm: { shape: "spark" },
mobile: { shape: "full", legend: "none", stack: true },
};
const fmtNum = (n: number): string => Math.round(n).toLocaleString();
/** The recolorable surface: the stream line + its fill (`line`). */
export type ChartLiveStreamSeries = { line?: SeriesConfig };
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
/** Seed history; the feed extends it live. Validated at runtime. */
data?: LiveStreamDatum;
// ── 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 ───────────────────────────────────────────────────────────
/** Recolor the line + fill. */
series?: ChartLiveStreamSeries;
/** Override value formatting (hero, axis, tooltip). */
valueFormatter?: (n: number) => string;
/** Replace, hide (`false`), or fully render the one-line narrative. */
narrative?: NarrativeProp;
};
export function ChartLiveStream({
data = liveStream,
label,
attribution,
className,
classNames,
size = "auto",
series,
valueFormatter,
narrative,
}: Props) {
const studio = useStudio();
const title = studio.text(
"title",
label ?? data.label ?? "Acme · Monthly Recurring Revenue",
{
label: "Chart title",
},
);
const lineColor = studio.color(
"lineColor",
series?.line?.color ?? "var(--chart-positive)",
{
label: "Line color",
presets: COLOR_PRESETS,
},
);
const attributionText = studio.text(
"attribution",
attribution ?? "Source: billing system · updated every 90 seconds",
{ label: "Attribution" },
);
const fmt = valueFormatter ?? fmtNum;
const parsed = React.useMemo(() => safeData(liveStreamSchema, data), [data]);
const safe = parsed.ok ? parsed.data : null;
if (!parsed.ok) {
return (
<ChartStates status="error" shape="area" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
if (!safe || safe.points.length < 8) {
return (
<ChartStates
status="empty"
shape="area"
empty={{
title: "Not enough history",
description: "At least eight readings are needed to seed the live stream.",
}}
>
{null}
</ChartStates>
);
}
return (
<ChartCard.Root
size={size}
className={cn(className, classNames?.root)}
exportable
exportTitle={title}
>
<StreamInner
data={safe}
title={title}
unit={safe.unit ?? ""}
narrative={narrative}
lineColor={lineColor}
attributionText={attributionText}
fmt={fmt}
classNames={classNames}
/>
</ChartCard.Root>
);
}
type InnerProps = {
data: LiveStreamDatum;
title: string;
unit: string;
narrative: NarrativeProp | undefined;
lineColor: string;
attributionText: string;
fmt: (n: number) => string;
classNames?: ChartClassNames;
};
function StreamInner({
data,
title,
unit,
narrative,
lineColor,
attributionText,
fmt,
classNames,
}: InnerProps) {
const spec = useSizePlan(STREAM_PLAN);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const active = useLiveActive(rootRef);
const seed = React.useMemo(() => data.points.map((p) => p.value), [data.points]);
const params = React.useMemo(() => feedParams(seed), [seed]);
const rngRef = React.useRef<() => number>(makeRng(seed.length * 9301 + 49297));
const [values, setValues] = React.useState<number[]>(seed);
// Reset the feed when the incoming seed changes — the sanctioned "adjust state
// while a prop changes" pattern (a guarded setState during render, not an
// effect). The RNG keeps running; only the visible window resets.
const [seedSnap, setSeedSnap] = React.useState(seed);
if (seedSnap !== seed) {
setSeedSnap(seed);
setValues(seed);
}
const stats = streamStats(values);
const latestLabel = `${fmt(stats.latest)}${unit}`;
const autoNarrative =
`Live · ${latestLabel} now, ${stats.deltaVsMean >= 0 ? "+" : ""}${(stats.deltaVsMean * 100).toFixed(1)}% ` +
`vs the ${values.length}-reading average (${fmt(stats.mean)}${unit}).`;
const chipText = `● live · ${latestLabel}`;
if (spec.shape === "spark") {
return (
<div ref={rootRef}>
<LiveKeyframes />
<SparkTile
label={title}
value={latestLabel}
chip={
narrative === false ? undefined : (
<ChartNarrative
text={typeof narrative === "string" ? narrative : chipText}
variant="chip"
className={classNames?.narrative}
/>
)
}
spark={
<StreamPlot
values={values}
params={params}
lineColor={lineColor}
active={false}
height={48}
minimal
/>
}
/>
</div>
);
}
const table: ChartTable = {
caption: `${title} — most recent readings`,
columns: ["Reading", `Value${unit ? ` (${unit.trim()})` : ""}`],
rows: values
.slice(-12)
.map((v, i) => [`t−${values.slice(-12).length - 1 - i}`, fmt(v)]),
};
const STEP_MS = data.intervalMs ?? 1100;
return (
<div ref={rootRef}>
<LiveKeyframes />
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
<ChartCard.HeroNumber>{latestLabel}</ChartCard.HeroNumber>
<p className="tabular flex items-center gap-2 text-sm text-muted-foreground">
<LiveBadge active={active} color={lineColor} />
{active ? "streaming" : "paused"} · {values.length}-reading window
</p>
</ChartCard.HeaderMain>
</ChartCard.Header>
{resolveNarrative(narrative ?? autoNarrative, undefined, {
className: classNames?.narrative,
})}
<ChartCard.Body className={classNames?.body}>
<ChartErrorBoundary minHeight={200}>
<ChartFigure label={autoNarrative} table={table}>
<StreamPlot
values={values}
params={params}
lineColor={lineColor}
active={active}
height={spec.shape === "compact" ? 170 : 220}
stepMs={STEP_MS}
onCommit={() =>
setValues((prev) => {
const next = stepValue(
prev[prev.length - 1],
rngRef.current(),
params,
);
return [...prev.slice(1), Math.round(next)];
})
}
/>
</ChartFigure>
</ChartErrorBoundary>
</ChartCard.Body>
<StudioTarget fieldKey="attribution" variant="inline">
<ChartCard.Footnote className={classNames?.footnote}>
{attributionText}
</ChartCard.Footnote>
</StudioTarget>
</div>
);
}
/** Hoisted, deduped keyframes for the live ping (React moves it to <head>). */
function LiveKeyframes() {
return (
<style
href="sx-live-kf"
precedence="default"
>{`@keyframes sx-live-ping{0%{transform:scale(1);opacity:0.5}80%,100%{transform:scale(2.6);opacity:0}}`}</style>
);
}
/** The pulsing "live" status dot — breathes only while streaming. */
function LiveBadge({ active, color }: { active: boolean; color: string }) {
return (
<span
aria-hidden
className="relative inline-flex size-2 shrink-0"
style={{ color }}
>
<span
className="absolute inline-flex size-2 rounded-full"
style={{ backgroundColor: "currentColor", opacity: active ? 0.9 : 0.4 }}
/>
{active && (
<span
className="absolute inline-flex size-2 rounded-full"
style={{
backgroundColor: "currentColor",
animation: "sx-live-ping 1400ms cubic-bezier(0,0,0.2,1) infinite",
}}
/>
)}
</span>
);
}
type PlotProps = {
/** The live window of values (oldest → newest). */
values: number[];
params: ReturnType<typeof feedParams>;
lineColor?: string;
active: boolean;
height?: number;
stepMs?: number;
/** Commit a new reading (drop oldest, append next). Called once per `stepMs`. */
onCommit?: () => void;
/** Strip axes + dot for the Small sparkline. */
minimal?: boolean;
};
/**
* Just the scrolling stream — an area + line that translates left continuously
* and commits a fresh reading each `stepMs`, with a pulsing leading dot. No
* card chrome. Renders a complete static frame whenever `active` is false. SVG.
*/
export function ChartLiveStreamPlot(props: PlotProps) {
return <StreamPlot {...props} />;
}
function StreamPlot({
values,
params,
lineColor = "var(--chart-positive)",
active,
height = 220,
stepMs = 1100,
onCommit,
minimal = false,
}: PlotProps) {
const uid = React.useId().replace(/:/g, "");
const W = minimal ? 96 : 720;
const PADT = minimal ? 2 : 8;
const plotH = height - PADT * 2;
const L = values.length;
const slotW = W / Math.max(1, L - 2);
const { lo, hi } = params;
const yOf = (v: number) => PADT + (1 - (v - lo) / (hi - lo || 1)) * plotH;
const gRef = React.useRef<SVGGElement | null>(null);
const accRef = React.useRef(0);
useRafLoop((dt) => {
accRef.current += dt;
if (accRef.current >= stepMs) {
accRef.current -= stepMs;
onCommit?.();
}
const progress = Math.min(1, accRef.current / stepMs);
gRef.current?.setAttribute(
"transform",
`translate(${(-slotW * progress).toFixed(2)},0)`,
);
}, active && !!onCommit);
const { line, area } = React.useMemo(() => {
const y = (v: number) => PADT + (1 - (v - lo) / (hi - lo || 1)) * plotH;
const pts = values.map((v, i) => [i * slotW, y(v)] as const);
const ln = "M" + pts.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" L");
const ar = `${ln} L${((L - 1) * slotW).toFixed(1)},${PADT + plotH} L0,${PADT + plotH} Z`;
return { line: ln, area: ar };
}, [values, slotW, plotH, PADT, lo, hi, L]);
const lastX = (L - 1) * slotW;
const lastY = yOf(values[L - 1]);
return (
<svg
viewBox={`0 0 ${W} ${height}`}
width="100%"
height="auto"
role="presentation"
style={{ display: "block" }}
>
<defs>
<linearGradient id={`ls-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={lineColor} stopOpacity={0.26} />
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
</linearGradient>
<clipPath id={`lsc-${uid}`}>
<rect x="0" y="0" width={W} height={height} />
</clipPath>
</defs>
<g clipPath={`url(#lsc-${uid})`}>
<g ref={gRef}>
<path d={area} fill={`url(#ls-${uid})`} stroke="none" />
<path
d={line}
fill="none"
stroke={lineColor}
strokeWidth={minimal ? 1.5 : 2}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{!minimal && (
<g>
{active && (
<circle
cx={lastX}
cy={lastY}
r={5}
fill={lineColor}
opacity={0.25}
style={{
transformBox: "fill-box",
transformOrigin: "center",
animation: "sx-live-ping 1400ms cubic-bezier(0,0,0.2,1) infinite",
}}
/>
)}
<circle cx={lastX} cy={lastY} r={3} fill={lineColor} />
</g>
)}
</g>
</g>
</svg>
);
}