TimeseriesChart

TimeseriesChart draws a primary metric over time, with optional comparison lines. A soft monotone curve passes through each sample. The parent supplies data, labels, and tooltip rows. The reverse-engineering notes live in rfcs/visitors-now-timeseries-chart.md.

import TimeseriesChart from "@haloid/azucar/ui/timeseries-chart";

The component is generic over Datum. Pass your rows as they are and describe them with accessors. It never reshapes your data. It does not sort either. Pass data ordered by date, ascending, or hover snapping picks the wrong point.

Basic use

Seven props are required: name, the two accessors, the two date formatters, getTooltipRows, and ariaLabel. name only seeds readable SVG def ids. xFormatter writes the axis labels and the hover chip. tooltipFormatter writes the tooltip heading.

The chart measures its parent and fills its width. It brings its own height: 320px, and 400px from the md viewport. So give the parent a width and nothing else. The w-120 below belongs to the demo.

const audits = [
{ date: new Date(2026, 0, 12), accuracy: 58.8 },
{ date: new Date(2026, 1, 3), accuracy: 63.4 },
{ date: new Date(2026, 1, 24), accuracy: 60.9 },
{ date: new Date(2026, 2, 19), accuracy: 66.1 },
{ date: new Date(2026, 3, 11), accuracy: 64.7 },
{ date: new Date(2026, 4, 7), accuracy: 92.4 },
{ date: new Date(2026, 5, 30), accuracy: 93.5 },
{ date: new Date(2026, 6, 20), accuracy: 78.5 },
];

render(
<div className="w-120">
  <TimeseriesChart
    name="Accuracy"
    data={audits}
    xAccessor={(point) => point.date}
    yAccessor={(point) => point.accuracy}
    xFormatter={(date) =>
      date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
    }
    tooltipFormatter={(date) =>
      date.toLocaleDateString("en-US", {
        month: "long",
        day: "numeric",
        year: "numeric",
      })
    }
    getTooltipRows={(point) => [
      { label: "Accuracy", value: point.accuracy.toFixed(1) + "%" },
    ]}
    ariaLabel="Reconciliation accuracy across eight audits in 2026"
  />
</div>,
);

Three details of the drawing are on purpose. The line bleeds off both edges: two synthetic buffer points dive down and out, so the series reads as a window into a longer history. The axis is only a bottom strip: the first and last date get labels, every point in between gets a 2px dot, and there is no y axis. The default y domain is [0, max / 0.8], which leaves 20% headroom above the tallest point. The floor is always zero unless you pass yDomain.

Compact charts

Set compact for charts inside cards or expanded rows. The chart is 192px tall, including its axis. It draws only supplied samples, without synthetic edge points. Axis dots are limited to keep dense histories readable.

referenceLine draws a dashed boundary. Set highlightAbove to shade the primary series above that boundary. comparisonLines adds curves from the same samples. Each curve supplies a key, color, and value accessor. The automatic domain includes comparison values and the boundary. The parent supplies legend labels and tooltip rows.

const samples = [
{ date: new Date("2026-08-01T00:00:00Z"), value: 4.2 },
{ date: new Date("2026-08-01T06:00:00Z"), value: 5.0 },
{ date: new Date("2026-08-01T12:00:00Z"), value: 6.4 },
{ date: new Date("2026-08-01T18:00:00Z"), value: 5.8 },
{ date: new Date("2026-08-02T00:00:00Z"), value: 4.8 },
];
render(
<div className="w-full">
  <TimeseriesChart
    name="Compact history"
    compact
    data={samples}
    xAccessor={(point) => point.date}
    yAccessor={(point) => point.value}
    referenceLine={{ value: 5, label: "Limit 5", highlightAbove: true }}
    xFormatter={(date) => date.toLocaleTimeString("en-US", { hour: "numeric", timeZone: "UTC" })}
    tooltipFormatter={(date) => date.toLocaleString("en-US", { timeZone: "UTC" })}
    getTooltipRows={(point) => [{ label: "Value", value: point.value.toFixed(1) }]}
    ariaLabel="Value over time. The dashed boundary is five; red shading marks values above five."
  />
</div>,
);

Comparison lines

This chart compares two values from each sample. The second line shares the primary line's scale and tooltip.

const samples = [
{ date: new Date("2026-08-01T00:00:00Z"), a: 2, b: 2 },
{ date: new Date("2026-08-01T06:00:00Z"), a: 3, b: 1.5 },
{ date: new Date("2026-08-01T12:00:00Z"), a: 4, b: 1.2 },
{ date: new Date("2026-08-01T18:00:00Z"), a: 3.5, b: 1.6 },
{ date: new Date("2026-08-02T00:00:00Z"), a: 2.5, b: 2.5 },
];
render(
<div className="w-full">
  <TimeseriesChart
    name="Comparison history"
    compact
    data={samples}
    xAccessor={(point) => point.date}
    yAccessor={(point) => point.a}
    colorAccessor={() => "var(--color-blue-4)"}
    comparisonLines={[{ key: "b", yAccessor: (point) => point.b, color: "var(--color-orange-4)" }]}
    xFormatter={(date) => date.toLocaleTimeString("en-US", { hour: "numeric", timeZone: "UTC" })}
    tooltipFormatter={(date) => date.toLocaleString("en-US", { timeZone: "UTC" })}
    getTooltipRows={(point) => [
      { label: "A", value: point.a.toFixed(1), color: "var(--color-blue-4)" },
      { label: "B", value: point.b.toFixed(1), color: "var(--color-orange-4)" },
    ]}
    ariaLabel="Blue is value A; orange is value B."
  />
</div>,
);

Hover and tooltip

Move across the demo above. The crosshair snaps to the nearest point instantly. Only the highlight glides. On hover the whole chart dims to 30% through an SVG mask, and one bright window of the curve, from the previous point to the next, stays at full strength. The window is a stroke-dash trick on the mask, so it slides along the curve as you move instead of jumping.

getTooltipRows fills the card body. Each TooltipRow is a label, a preformatted string value, and an optional dot color. Without a color the first row gets a white dot and later rows gray.

Two gaps. The tooltip, its dots, and the hover chip are hardcoded dark hex values, not tokens. The card is dark in both themes. That copies the reference, and a token pass has not happened. The tooltip card is a fixed 192px wide. Long labels truncate poorly.

Bands and color

yAxisBands describes named ranges of the y axis. Each band boundary inside the domain draws a hairline gridline, and each band labels itself just above its lower boundary, with its color as a 3px dot. colorAccessor gives each datum a color, which turns the stroke and area into one horizontal gradient. This is the full recipe from the reconciliation overview:

const audits = [
{ date: new Date(2026, 0, 12), accuracy: 58.8 },
{ date: new Date(2026, 1, 3), accuracy: 63.4 },
{ date: new Date(2026, 1, 24), accuracy: 60.9 },
{ date: new Date(2026, 2, 19), accuracy: 66.1 },
{ date: new Date(2026, 3, 11), accuracy: 64.7 },
{ date: new Date(2026, 4, 7), accuracy: 92.4 },
{ date: new Date(2026, 5, 30), accuracy: 93.5 },
{ date: new Date(2026, 6, 20), accuracy: 78.5 },
];

const accuracyColor = (value) => {
if (value < 60) return "var(--color-red-4)";
if (value < 90) return "var(--color-orange-4)";
return "var(--color-green-4)";
};

const bands = [
{
  min: 0,
  max: 60,
  color: "var(--color-red-4)",
  label: "Out of sync",
  detail: "<60%",
},
{
  min: 60,
  max: 90,
  color: "var(--color-orange-4)",
  label: "Partially reconciled",
  detail: "60-90%",
},
{
  min: 90,
  max: 100,
  color: "var(--color-green-4)",
  label: "Reconciled",
  detail: ">=90%",
},
];

render(
<div className="w-120">
  <TimeseriesChart
    name="Accuracy bands"
    data={audits}
    xAccessor={(point) => point.date}
    yAccessor={(point) => point.accuracy}
    colorAccessor={(point) => accuracyColor(point.accuracy)}
    colorTransition="abrupt"
    yAxisBands={bands}
    yDomain={[0, 100]}
    xFormatter={(date) =>
      date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
    }
    tooltipFormatter={(date) =>
      date.toLocaleDateString("en-US", {
        month: "long",
        day: "numeric",
        year: "numeric",
      })
    }
    getTooltipRows={(point) => [
      {
        label: "Accuracy",
        value: point.accuracy.toFixed(1) + "%",
        color: accuracyColor(point.accuracy),
      },
    ]}
    ariaLabel="Accuracy history with reconciliation state bands"
  />
</div>,
);

Color transitions

colorTransition controls how one color hands over to the next.

  • smooth blends across the whole span between two stops.
  • abrupt compresses each change into a 10px window, mixed in OKLCH. This is the house choice: the color change reads as an event, not a wash.
  • step switches hard, with no blend.

Where the stops sit depends on the props. With colorAccessor alone, each datum places a stop at its own x position. With yAxisBands present, the chart ignores datum positions and measures the drawn path instead. It places each change where the curve crosses a band boundary. The curve is a spline, so it crosses thresholds between the points that caused the change. Datum-positioned stops would drift off the gridlines. One detail of that mode: smooth and abrupt produce the same 10px blend, so only step differs.

Markers and guides

getMarker pins a small labeled circle above each point, in a strip along the top of the chart. Labels are one or two characters. Hover a point and every other marker dims. showGuides adds a faint dashed vertical guide under each point. Faint is the design: the guides exist to connect marker to point. Nothing in the product uses either prop yet, so treat this section as capability, not precedent.

const audits = [
{ date: new Date(2026, 2, 19), accuracy: 66.1, source: "Physical audit" },
{ date: new Date(2026, 3, 11), accuracy: 64.7, source: "Physical audit" },
{ date: new Date(2026, 4, 7), accuracy: 92.4, source: "NetSuite" },
{ date: new Date(2026, 5, 30), accuracy: 93.5, source: "Physical audit" },
{ date: new Date(2026, 6, 20), accuracy: 78.5, source: "NetSuite" },
];

render(
<div className="w-120">
  <TimeseriesChart
    name="Audit markers"
    data={audits}
    xAccessor={(point) => point.date}
    yAccessor={(point) => point.accuracy}
    getMarker={(point) => ({
      label: point.source === "NetSuite" ? "N" : "A",
      ariaLabel: "Updated by " + point.source,
    })}
    showGuides
    xFormatter={(date) =>
      date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
    }
    tooltipFormatter={(date) =>
      date.toLocaleDateString("en-US", { month: "long", day: "numeric" })
    }
    getTooltipRows={(point) => [
      { label: "Accuracy", value: point.accuracy.toFixed(1) + "%" },
      { label: "Updated by", value: point.source },
    ]}
    ariaLabel="Accuracy history with one marker per update source"
  />
</div>,
);

Loading and empty

Two loading states, with different meanings.

  • data undefined is the first load. An opaque cover hides the whole chart, then fades out over 0.35s when data arrives.
  • isLoading with data present is a refetch. The stale chart stays visible and pulses. Nothing swaps until the new data does.

An empty array shows No history yet.

Motion

animateOnMount is off by default, and should stay off for page loads. When on, the chart wipes in left to right behind an animated clip, and markers pop in staggered so each appears as the wipe edge passes it. The reveal is reserved for in-page data swaps, such as a future granularity toggle that remounts the chart with a key. After the first reveal a latch swaps every animated element for its static twin, so the chart is inert once settled. Reduced-motion users get no reveal.

In the product

The reconciliation overview uses apps/north/src/components/pages/reconciliation/accuracy-history-chart.tsx. Its bands derive from RECONCILIATION_ACCURACY_THRESHOLDS. Colors come from reconciliationAccuracyColor.

// apps/north/src/components/pages/reconciliation/accuracy-history-chart.tsx
<TimeseriesChart
name="Accuracy history"
data={transitionSettled ? orderedHistory : undefined}
xAccessor={xAccessor}
yAccessor={yAccessor}
colorAccessor={colorAccessor}
colorTransition="abrupt"
xFormatter={axisFormatter}
tooltipFormatter={tooltipFormatter}
getTooltipRows={getTooltipRows}
yAxisBands={accuracyYAxisBands}
yDomain={ACCURACY_DOMAIN}
ariaLabel="Reconciliation accuracy across NetSuite and physical audit updates. Red is below 60%, orange is 60% to below 90%, and green is 90% or higher."
isLoading={isLoading}
/>

Copy three habits of this wrapper. It sorts the history ascending before it passes it, because the chart will not. It holds data undefined until the route transition settles, because a chart mounted mid-slide blocks the main thread and makes the slide skip. Its ariaLabel spells out what the colors mean, because a screen reader gets the label and nothing else. The overview then extends the chart to the full viewport width inside a Tabs panel with clip off.

API

Default export. Generic over Datum.

interface TooltipRow {
label: string;
value: string; // preformatted; the chart does not format values
color?: string; // dot color; defaults to white for row 0, gray after
}
interface Marker {
label: string; // one or two characters
ariaLabel: string;
}
interface YAxisBand {
min: number;
max: number;
color: string; // the 3px dot next to the band label
label: string;
detail?: string; // quiet suffix, e.g. "<60%"
}
interface TimeseriesChartProps<Datum> {
name: string; // seeds readable SVG def ids
data?: readonly Datum[]; // undefined = first load; sorted ascending by date
xAccessor: (datum: Datum) => Date;
yAccessor: (datum: Datum) => number;
xFormatter: (date: Date) => string; // axis labels and hover chip
tooltipFormatter: (date: Date) => string; // tooltip heading
getTooltipRows: (datum: Datum) => TooltipRow[];
getMarker?: (datum: Datum) => Marker;
colorAccessor?: (datum: Datum) => string;
colorTransition?: "smooth" | "abrupt" | "step"; // default "smooth"
yAxisBands?: readonly YAxisBand[];
yDomain?: readonly [number, number]; // default [0, max / 0.8]
showGuides?: boolean; // default false
compact?: boolean; // default false; 192px tall with no synthetic edge points
referenceLine?: { value: number; label: string; highlightAbove?: boolean };
comparisonLines?: readonly { key: string; yAccessor: (datum: Datum) => number; color: string }[];
animateOnMount?: boolean; // default false
ariaLabel: string;
isLoading?: boolean; // default false; pulses only when data is present
}

The chart has two fixed sizes. It does not accept a custom height or className. Tooltip colors still use fixed dark values.