Sextant
← Playground

Anomaly Time Series

free

Area chart that auto-detects anomalies via z-score and surfaces the top deviation as a plain-language narrative.

Editable — click an element to tune it

npx shadcn add @sextant/chart-anomaly-timeseries

Monthly recurring revenue

4,758,780

1 anomaly flagged at 2σ

  • Revenue
  • Anomalies

Revenue dropped to 3,950,000 on Feb 5, 2026, 2.6σ below a 4,500,741.717 baseline — driven by Stripe outage.

Acme · billing data · updated hourly

Generated code

<ChartAnomalyTimeseries />

Source — this is the whole widget

"use client";

import * as React from "react";
import {
  Area,
  AreaChart,
  CartesianGrid,
  ComposedChart,
  Scatter,
  XAxis,
  YAxis,
} from "recharts";
import { format, parseISO } from "date-fns";

import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart";
import {
  ChartNarrative,
  resolveNarrative,
  type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
import {
  detectAnomalies,
  explainTopAnomaly,
} from "@/registry/default/sextant-insights/stats";
import type { Insight } from "@/registry/default/sextant-insights/types";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { SparkTile } from "@/registry/default/widget-kit/spark-tile";
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 { safeData } from "@/registry/default/sextant-contracts/contracts";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import { anomalySeriesSchema, anomalySeries, type AnomalyPoint } from "./data";

// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export { anomalySeriesSchema, type AnomalyPoint } from "./data";

const COLOR_PRESETS = [
  { name: "Maroon (brand)", value: "var(--chart-1)" },
  { name: "Ink", value: "var(--chart-2)" },
  { name: "Cool gray", value: "var(--chart-3)" },
  { name: "Sage", value: "var(--chart-positive)" },
  { name: "Terracotta", value: "var(--chart-negative)" },
  { name: "Honey", value: "var(--chart-warning)" },
];

const THRESHOLD_OPTIONS = [
  { value: "1.5", label: "1.5σ — loose", sample: "more flags" },
  { value: "2", label: "2σ — standard", sample: "default" },
  { value: "3", label: "3σ — strict", sample: "rare events" },
];

const MARKER_OPTIONS = [
  { value: "circle", label: "Circle", sample: "●" },
  { value: "diamond", label: "Diamond", sample: "◆" },
  { value: "ring", label: "Ring", sample: "○" },
];

const ANOMALY_PLAN: SizePlan = {
  lg: { shape: "full", height: "h-64", legend: "full", ticksX: 6 },
  md: { shape: "full", height: "h-56", legend: "inline", ticksX: 4 },
  sm: { shape: "spark" },
  mobile: { shape: "full", height: "h-64", legend: "inline", ticksX: 4, stack: true },
};

/** A row of the main series with its anomaly value attached (null when normal). */
export type AnomalyChartDatum = AnomalyPoint & { anomaly: number | null };

/**
 * The two encodings this widget draws: the `value` area/line and the `anomaly`
 * markers riding on top of it. Each is independently configurable.
 */
export type AnomalyTimeseriesSeries = {
  value?: SeriesConfig;
  anomaly?: SeriesConfig;
};

type Props = {
  // ── Data ────────────────────────────────────────────────────────────────
  data?: AnomalyPoint[];

  // ── Card chrome ───────────────────────────────────────────────────────────
  label?: string;
  metric?: 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 ───────────────────────────────────────────────────────────
  /** Per-series color / rename / hide: `series={{ anomaly: { color: "var(--brand)" } }}`. */
  series?: AnomalyTimeseriesSeries;
  /** Override number formatting everywhere (axis, hero, tooltip) — e.g. EUR/locale. */
  valueFormatter?: (n: number) => string;
  /** Replace, hide (`false`), or fully render the one-line narrative. */
  narrative?: NarrativeProp;
};

/** The widget's default value formatter — plain locale grouping. */
function fmtValue(n: number): string {
  return n.toLocaleString();
}

/**
 * Pure: flag anomalies ON the main series (keyed by date) so the markers share
 * the chart's category axis and land on the line. Exported so the headless
 * `<ChartAnomalyTimeseriesPlot>` and your own code merge the same way.
 */
export function buildAnomalyChartData(
  data: AnomalyPoint[],
  anomalies: Array<AnomalyPoint & { z: number }>,
): AnomalyChartDatum[] {
  const flagged = new Map(anomalies.map((a) => [a.date, a.value]));
  return data.map((d) => ({
    ...d,
    anomaly: flagged.has(d.date) ? (flagged.get(d.date) as number) : null,
  }));
}

export function ChartAnomalyTimeseries(props: Props) {
  // Validate consumer data at the boundary: a bad shape shows the error
  // state instead of crashing. The built-in demo data is trusted, so skip it.
  const parsed =
    props.data === undefined ? null : safeData(anomalySeriesSchema, props.data);
  if (parsed && !parsed.ok) {
    return (
      <ChartStates status="error" shape="area" error={{ message: parsed.error }}>
        {null}
      </ChartStates>
    );
  }
  return <ChartAnomalyTimeseriesImpl {...props} />;
}

function ChartAnomalyTimeseriesImpl({
  data = anomalySeries,
  label,
  metric = "Revenue",
  attribution,
  className,
  classNames,
  size = "auto",
  series,
  valueFormatter,
  narrative,
}: Props) {
  const studio = useStudio();

  const title = studio.text("title", label ?? "Monthly recurring revenue", {
    label: "Chart title",
  });
  const threshold = studio.format("threshold", "2", {
    label: "Anomaly threshold",
    options: THRESHOLD_OPTIONS,
  });
  // `series.*.color` sets the default; Studio (when mounted) still edits on top.
  const seriesColor = studio.color(
    "seriesColor",
    series?.value?.color ?? "var(--chart-3)",
    {
      label: "Series color",
      presets: COLOR_PRESETS,
    },
  );
  const markerColor = studio.color(
    "markerColor",
    series?.anomaly?.color ?? "var(--chart-1)",
    {
      label: "Marker color",
      presets: COLOR_PRESETS,
    },
  );
  const markerStyle = studio.format("markerStyle", "circle", {
    label: "Marker style",
    options: MARKER_OPTIONS,
  });
  const showCallout = studio.toggle("showCallout", true, {
    label: "Show narrative callout",
    onLabel: "Visible",
    offLabel: "Hidden",
  });
  const attributionText = studio.text(
    "attribution",
    attribution ?? "Acme · billing data · updated hourly",
    { label: "Attribution" },
  );

  const valueLabel = series?.value?.label ?? metric;
  const anomalyLabel = series?.anomaly?.label ?? "Anomalies";
  const valueHidden = series?.value?.hidden ?? false;
  const anomalyHidden = series?.anomaly?.hidden ?? false;
  const fmt = valueFormatter ?? fmtValue;

  const config = {
    value: { label: valueLabel, color: seriesColor },
  } satisfies ChartConfig;

  const anomalies = React.useMemo(
    () => detectAnomalies(data, (r) => r.value, parseFloat(threshold)),
    [data, threshold],
  );

  // The single most extreme point drives the narrative — explained against the
  // same global baseline the markers use, so the sentence states how many σ it
  // sits *above/below the baseline mean* (not a noisy first-observation anchor).
  const topAnomaly = React.useMemo(
    () => explainTopAnomaly(data, (r) => r.value, parseFloat(threshold)),
    [data, threshold],
  );

  const insight: Insight | undefined = topAnomaly
    ? {
        kind: "anomaly",
        metric,
        sigma: topAnomaly.z,
        date: format(parseISO(topAnomaly.point.date), "MMM d, yyyy"),
        value: topAnomaly.value,
        baseline: topAnomaly.baseline.mean,
        driver: topAnomaly.point.driver,
      }
    : undefined;

  const statusText =
    anomalies.length === 0
      ? "No anomalies above threshold"
      : `${anomalies.length} anomal${anomalies.length === 1 ? "y" : "ies"} flagged at ${threshold}σ`;

  return (
    <ChartCard.Root size={size} className={cn(className, classNames?.root)}>
      <AnomalyInner
        data={data}
        anomalies={anomalies}
        config={config}
        insight={insight}
        narrative={narrative}
        showCallout={showCallout}
        title={title}
        metric={metric}
        valueLabel={valueLabel}
        anomalyLabel={anomalyLabel}
        valueHidden={valueHidden}
        anomalyHidden={anomalyHidden}
        statusText={statusText}
        seriesColor={seriesColor}
        markerColor={markerColor}
        markerStyle={markerStyle}
        valueFormatter={valueFormatter}
        fmt={fmt}
        attributionText={attributionText}
        classNames={classNames}
      />
    </ChartCard.Root>
  );
}

type InnerProps = {
  data: AnomalyPoint[];
  anomalies: Array<AnomalyPoint & { z: number }>;
  config: ChartConfig;
  insight: Insight | undefined;
  narrative: NarrativeProp | undefined;
  showCallout: boolean;
  title: string;
  metric: string;
  valueLabel: string;
  anomalyLabel: string;
  valueHidden: boolean;
  anomalyHidden: boolean;
  statusText: string;
  seriesColor: string;
  markerColor: string;
  markerStyle: string;
  valueFormatter?: (n: number) => string;
  fmt: (n: number) => string;
  attributionText: string;
  classNames?: ChartClassNames;
};

function AnomalyInner({
  data,
  anomalies,
  config,
  insight,
  narrative,
  showCallout,
  title,
  valueLabel,
  anomalyLabel,
  valueHidden,
  anomalyHidden,
  statusText,
  seriesColor,
  markerColor,
  markerStyle,
  valueFormatter,
  fmt,
  attributionText,
  classNames,
}: InnerProps) {
  const spec = useSizePlan(ANOMALY_PLAN);
  const last = data[data.length - 1]?.value;
  const latest = last == null ? undefined : fmt(last);
  // Resolve the narrative once so we can gate the StudioTarget on a real node:
  // with defaults this is the auto-insight element (or null when there's no
  // anomaly / `narrative={false}`), preserving the original `&& insight` gating.
  const narrativeNode = resolveNarrative(narrative, insight, {
    className: classNames?.narrative,
  });

  if (spec.shape === "spark") {
    return (
      <SparkTile
        label={title}
        value={latest}
        chip={<ChartNarrative text={statusText} variant="chip" />}
        spark={
          <ChartContainer config={config} className="h-full w-full">
            <AreaChart data={data} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
              <Area
                dataKey="value"
                type="monotone"
                stroke={seriesColor}
                strokeWidth={1.5}
                fill={seriesColor}
                fillOpacity={0.12}
                isAnimationActive={false}
                dot={false}
              />
            </AreaChart>
          </ChartContainer>
        }
      />
    );
  }

  return (
    <>
      <ChartCard.Header className={classNames?.header}>
        <ChartCard.HeaderMain>
          <StudioTarget fieldKey="title" variant="inline">
            <ChartCard.Label>{title}</ChartCard.Label>
          </StudioTarget>
          <ChartCard.HeroNumber>{latest}</ChartCard.HeroNumber>
          <p className="tabular text-sm text-muted-foreground">{statusText}</p>
        </ChartCard.HeaderMain>
        {(spec.legend === "full" || spec.legend === "inline") && (
          <ul className="flex flex-col gap-1.5 text-right group-data-[size=mobile]/chart-card:flex-row group-data-[size=mobile]/chart-card:gap-4 group-data-[size=mobile]/chart-card:text-left">
            {!valueHidden && (
              <li className="smallcaps inline-flex items-center justify-end gap-2">
                <span
                  aria-hidden
                  className="size-2 rounded-sm"
                  style={{ backgroundColor: seriesColor }}
                />
                {valueLabel}
              </li>
            )}
            {!anomalyHidden && (
              <li className="smallcaps inline-flex items-center justify-end gap-2">
                <AnomalyGlyph color={markerColor} style={markerStyle} />
                {anomalyLabel}
              </li>
            )}
          </ul>
        )}
      </ChartCard.Header>

      {showCallout && narrativeNode && (
        <StudioTarget fieldKey="showCallout" variant="inline">
          {narrativeNode}
        </StudioTarget>
      )}

      <ChartCard.Body className={classNames?.body}>
        <ChartAnomalyTimeseriesPlot
          data={data}
          anomalies={anomalies}
          config={config}
          heightClass={spec.height}
          ticksX={spec.ticksX}
          seriesColor={seriesColor}
          markerColor={markerColor}
          markerStyle={markerStyle}
          valueFormatter={valueFormatter}
          hidden={{ value: valueHidden, anomaly: anomalyHidden }}
        />
      </ChartCard.Body>

      <StudioTarget fieldKey="attribution" variant="inline">
        <ChartCard.Footnote className={classNames?.footnote}>
          {attributionText}
        </ChartCard.Footnote>
      </StudioTarget>
    </>
  );
}

type PlotProps = {
  /** The raw main series. */
  data: AnomalyPoint[];
  /** Detected anomalies (date + value + z) to flag onto the series. */
  anomalies: Array<AnomalyPoint & { z: number }>;
  /** Series color + label (a shadcn ChartConfig keyed by `value`). */
  config: ChartConfig;
  heightClass?: string;
  ticksX?: number;
  seriesColor?: string;
  markerColor?: string;
  markerStyle?: string;
  /** Override axis + tooltip number formatting (e.g. EUR/locale). */
  valueFormatter?: (n: number) => string;
  /** Hide individual encodings (default: all shown). */
  hidden?: { value?: boolean; anomaly?: boolean };
};

/**
 * Just the chart-drawing piece — the value area plus the anomaly markers riding
 * on it, with axes and tooltip but no card chrome, narrative, or states. Takes
 * the raw series + the detected anomalies and merges them internally, so you can
 * drop it into your own layout or compose several side by side.
 */
export function ChartAnomalyTimeseriesPlot({
  data,
  anomalies,
  config,
  heightClass = "h-64",
  ticksX = 6,
  seriesColor = "var(--chart-3)",
  markerColor = "var(--chart-1)",
  markerStyle = "circle",
  valueFormatter,
  hidden,
}: PlotProps) {
  // Unique per instance so several Plots on one page don't share a gradient.
  const gradientId = `at-fill-${React.useId().replace(/:/g, "")}`;
  const chartData = React.useMemo(
    () => buildAnomalyChartData(data, anomalies),
    [data, anomalies],
  );
  // Default axis ticks: compact "k" above 1000. Overridden only when a custom
  // valueFormatter is supplied (so the default render stays byte-identical).
  const formatAxis = valueFormatter
    ? (v: number) => valueFormatter(v)
    : (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : `${v}`);
  // The tooltip's `formatter` replaces the whole row, so when overriding the
  // value format we re-emit the dot + label to match the default look.
  // Always format — even with no `valueFormatter` — so the tooltip matches the
  // axis instead of falling back to the generic raw-number dump.
  const formatTip =
    valueFormatter ??
    ((v: number) => (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : `${v}`));
  const tooltipFormatter: React.ComponentProps<
    typeof ChartTooltipContent
  >["formatter"] = (value, name, item) => (
    <>
      <span
        aria-hidden
        className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
        style={{ backgroundColor: (item as { color?: string }).color }}
      />
      <span className="flex flex-1 items-center justify-between gap-3 leading-none">
        <span className="text-muted-foreground">
          {config[name as string]?.label ?? String(name)}
        </span>
        <span className="font-mono font-medium text-foreground tabular-nums">
          {formatTip(Number(value))}
        </span>
      </span>
    </>
  );

  return (
    <ChartContainer config={config} className={cn(heightClass, "w-full")}>
      <ComposedChart data={chartData} margin={{ left: 0, right: 8, top: 8 }}>
        <defs>
          <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
            <stop offset="5%" stopColor={seriesColor} stopOpacity={0.32} />
            <stop offset="95%" stopColor={seriesColor} stopOpacity={0.02} />
          </linearGradient>
        </defs>
        <CartesianGrid vertical={false} stroke="var(--chart-grid)" />
        <XAxis
          dataKey="date"
          tickFormatter={(v: string) => format(parseISO(v), "MMM d").toUpperCase()}
          tickLine={false}
          axisLine={false}
          tickMargin={10}
          fontSize={10}
          minTickGap={ticksX >= 6 ? 32 : 48}
          className="smallcaps"
        />
        <YAxis
          tickFormatter={(v: number) => formatAxis(v)}
          tickLine={false}
          axisLine={false}
          width={36}
          fontSize={10}
          className="smallcaps tabular"
        />
        <ChartTooltip
          cursor={{
            stroke: "var(--foreground)",
            strokeOpacity: 0.25,
            strokeDasharray: "2 4",
          }}
          content={
            <ChartTooltipContent
              labelFormatter={(v) => format(parseISO(String(v)), "MMM d, yyyy")}
              formatter={tooltipFormatter}
            />
          }
        />
        {!hidden?.value && (
          <Area
            dataKey="value"
            type="monotone"
            stroke={seriesColor}
            strokeWidth={1.5}
            fill={`url(#${gradientId})`}
          />
        )}
        {/* Anomaly markers ride the `anomaly` field on the main series, so
            they share the category axis and sit on the line at the right
            dates (not bunched left by a separate-data Scatter's index). */}
        {!hidden?.anomaly && (
          <Scatter
            dataKey="anomaly"
            tooltipType="none"
            fill={markerColor}
            shape={(props: {
              cx?: number;
              cy?: number;
              payload?: { anomaly?: number | null };
            }) =>
              // Non-anomaly rows carry a null `anomaly` and would otherwise
              // paint a marker at cy=0 (a row of dots along the top); skip them.
              props.payload?.anomaly == null || props.cy == null ? (
                <g />
              ) : (
                <AnomalyMarker
                  cx={props.cx ?? 0}
                  cy={props.cy}
                  color={markerColor}
                  style={markerStyle}
                />
              )
            }
            isAnimationActive={false}
          />
        )}
      </ComposedChart>
    </ChartContainer>
  );
}

function AnomalyMarker({
  cx,
  cy,
  color,
  style,
}: {
  cx: number;
  cy: number;
  color: string;
  style: string;
}) {
  if (style === "diamond") {
    return (
      <g transform={`translate(${cx} ${cy})`}>
        <rect
          x={-5}
          y={-5}
          width={10}
          height={10}
          transform="rotate(45)"
          fill={color}
          stroke="var(--background)"
          strokeWidth={1.5}
        />
      </g>
    );
  }
  if (style === "ring") {
    return (
      <circle
        cx={cx}
        cy={cy}
        r={5}
        fill="var(--background)"
        stroke={color}
        strokeWidth={2}
      />
    );
  }
  // circle (default)
  return (
    <circle
      cx={cx}
      cy={cy}
      r={4}
      fill={color}
      stroke="var(--background)"
      strokeWidth={1.5}
    />
  );
}

function AnomalyGlyph({ color, style }: { color: string; style: string }) {
  return (
    <span
      aria-hidden
      className="inline-block size-2"
      style={{
        backgroundColor: style === "ring" ? "transparent" : color,
        border: style === "ring" ? `1.5px solid ${color}` : "none",
        borderRadius: style === "diamond" ? 0 : "1px",
        transform: style === "diamond" ? "rotate(45deg)" : undefined,
      }}
    />
  );
}