Sextant
← Playground

Live Metric Stream

free

A 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-stream
State
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).

MRR — most recent readings
ReadingValue (USD)
t−114,423,976
t−104,440,267
t−94,462,786
t−84,463,380
t−74,451,363
t−64,470,849
t−54,494,771
t−44,507,927
t−34,510,344
t−24,495,190
t−14,508,589
t−04,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>
  );
}