Skip to content
KONIGI

Dashboards / Visual representation / Time series

21 of 22

Time series

The viewer needs to see how a value changed over time and spot the moment it changed.

Updated September 9, 2026

Problem

Someone is looking at a number that moved. What they need is not the number. They need the shape that produced it, and the moment the shape changed, because that moment is usually the only thing they can act on.

Solution

Time on the horizontal, value on the vertical, points joined by a line. That part has been settled since Playfair and nobody needs to reconsider it.

What makes or breaks the panel is everything the line implies that the data never said. A time series on a screen is always sampled, always aggregated down to the pixels available, and the aggregation is a decision the viewer can’t see. Grafana makes one piece of this explicit, which is instructive: connect null values has three positions, Never, Always, and Threshold, and the same gap in the same data reads as an outage, as a straight line through nothing, or as a judgment call, depending on which one someone picked. Most products make that choice for you and don’t mention it.

The second thing is the y-axis. A line chart doesn’t need a zero baseline the way a bar chart does, because the line encodes change and the bar encodes magnitude. That freedom is also how a 2% drift gets drawn as a cliff.

Use when

The question has “when” in it. When did it start, when did it recover, is it still happening. Also when the viewer needs a rate rather than a total, which covers most of operations.

Don’t use when

The categories have no natural order, or there are more series than a person can follow. Twelve lines on one chart is a legend exercise. If the answer is a distribution rather than a trajectory, a histogram or a heatmap will say more.

Trade-offs

The line asserts continuity between points that were sampled discretely, so it always claims to know slightly more than it does. Downsampling to fit the panel width can erase the spike that mattered; a five-second outage doesn’t survive a query returning one point per minute, and nothing on the panel admits this. Averaging over the rollup window smooths in the same direction, always toward “fine”. Y-axis autoscaling makes every chart look eventful, which teaches the viewer to ignore all of them.

Checklist

  • What is the interval between points, and can the viewer tell?
  • What happens to a gap: drawn as a gap, connected, or filled with zero?
  • Is missing data visually distinct from a real zero?
  • Does the y-axis start at zero, and if not, is that defensible for this metric?
  • Does the axis rescale as data arrives, and does that make small movement look large?
  • What aggregation runs when the range is wide: average, max, or last? Would max change the answer?
  • Can the viewer see the time range and change it without leaving the panel?
  • Where several panels share a page, do they share a time range, and a y-scale where it matters?
  • Are units on the axis, and are they the units the viewer thinks in?
  • How many series before this stops being readable, and what happens at that count?

Compare

Grafana exposes the gap decision as a per-panel setting with three positions, which is the most honest treatment in the category and also the reason two panels on one dashboard can disagree about the same outage. Netdata samples per second and draws it, so the chart keeps detail most tools have averaged away before it reaches the panel, at the cost of a page that never sits still. Honeycomb answers the same question with a heatmap instead of a line, arguing that a line drawn through a percentile hides the distribution that would explain it. Sentry puts the series inline on every issue row as a sparkline, the same encoding at list density, doing a job a full panel would be too expensive for.

Sparkline is this pattern shrunk to sit beside a number. Anomaly band and threshold line are the two ways to put “what should this look like” on the same axes. Annotation answers the question the line provokes, which is what happened at that moment. Time-range picker is the control that makes the chart answerable at all, and zoom and pan is how the viewer narrows once they’ve found the moment.

Time series anatomy A line chart with a gap in the data, drawn against a stated aggregation interval. Below, the same two percent drift plotted with the axis starting at zero and with the axis fitted to the data, where it reads as a cliff. Anatomy Checkout P95 · 1m interval 400 0 14:20 14:00 14:40 1 2 3 1 THE INTERVAL Every series on a screen is aggregated down to the pixels available, and the aggregation is a decision nobody can see. 2 THE GAP Connect nulls has three positions, and the same gap reads as an outage, a straight line, or a judgment call. 3 THE VALUE AXIS A line encodes change, so it needn't start at zero. That freedom is the next panel. The same two percent, two axes 100 0 Axis from zero 98.2 97.8 Axis fitted to the data
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Recharts, with the two decisions a line hides made explicit: the interval in the header, a closed set of gap treatments, and an axis that starts at zero unless the caller asks otherwise and the panel says so.

npm
recharts
Tokens
--card--muted--muted-foreground--border--chart-1

Checkout P95 · 1m intervalms

14:0014:2014:400100200300400

TimeSeries.tsxThe header names the interval, gaps are drawn as gaps, joined, or joined under a threshold, and the axis starts at zero unless told to fit.

import { CartesianGrid, Line, LineChart, ReferenceArea, Tooltip, XAxis, YAxis } from "recharts";

export type Sample = { t: number; value: number | null };

/**
 * Grafana's three positions for a gap, made a closed set. `never` draws the
 * gap; `always` draws a straight line through nothing; a threshold joins
 * only gaps shorter than it, which is the judgment call written down.
 */
export type Gaps = "never" | "always" | { joinUnderMs: number };

const hhmm = (t: number) => new Date(t).toISOString().slice(11, 16);
const MIN = 60_000;

/** The null runs, as [first, last] timestamps. */
const runs = (data: Sample[]) => {
  const out: [number, number][] = [];
  for (const s of data) {
    if (s.value !== null) continue;
    const last = out[out.length - 1];
    if (last && s.t - last[1] <= 1.5 * (data[1].t - data[0].t)) last[1] = s.t;
    else out.push([s.t, s.t]);
  }
  return out;
};

/** Fill the gaps shorter than the threshold by straight line, leave the rest. */
const join = (data: Sample[], underMs: number): Sample[] => {
  const out = data.map((s) => ({ ...s }));
  for (const [a, b] of runs(data)) {
    if (b - a + MIN > underMs) continue;
    const i = out.findIndex((s) => s.t === a), j = out.findIndex((s) => s.t === b);
    const y0 = out[i - 1]?.value, y1 = out[j + 1]?.value;
    if (y0 == null || y1 == null) continue;
    for (let k = i; k <= j; k++) out[k].value = y0 + ((y1 - y0) * (k - i + 1)) / (j - i + 2);
  }
  return out;
};

/**
 * Time on the horizontal, value on the vertical, and the two decisions the
 * line would otherwise hide, stated: the interval is in the header, the gap
 * treatment is a prop with three positions, and the axis starts at zero
 * unless the caller says otherwise, in which case the panel says so too.
 */
export function TimeSeries({ title, interval, unit, data, gaps = "never", baseline = "zero", max, width = 400, height = 160 }: {
  title: string;
  /** The aggregation interval, as the viewer should read it: "1m", "5m", "1h". */
  interval: string;
  unit: string;
  data: Sample[];
  gaps?: Gaps;
  baseline?: "zero" | "fit";
  /** Pin the top of the axis, so panels on one page can share a scale. */
  max?: number;
  width?: number;
  height?: number;
}) {
  const series = typeof gaps === "object" ? join(data, gaps.joinUnderMs) : data;
  const gapRuns = runs(series);
  // Tick every 20 minutes on the clock, not from the first sample.
  const step = 20 * MIN;
  const ticks: number[] = [];
  for (let t = Math.ceil(data[0].t / step) * step; t <= data[data.length - 1].t; t += step) ticks.push(t);
  const stroke = "hsl(var(--chart-1))";
  const tick = { fontSize: 10, fill: "hsl(var(--muted-foreground))" };

  return (
    <div>
      <p className="flex justify-between border-b pb-2 text-[11px] uppercase tracking-wide text-muted-foreground">
        <span>{title} · {interval} interval</span>
        <span>{unit}</span>
      </p>
      <div className="mt-3 overflow-x-auto">
        <LineChart width={width} height={height} data={series} margin={{ top: 6, right: 8, bottom: 0, left: -16 }}>
          <CartesianGrid vertical={false} stroke="hsl(var(--border))" />
          <XAxis interval={0} dataKey="t" type="number" domain={["dataMin", "dataMax"]} ticks={ticks} tickFormatter={hhmm} tick={tick} stroke="hsl(var(--border))" />
          <YAxis domain={[baseline === "zero" ? 0 : "auto", max ?? "auto"]} tick={tick} stroke="hsl(var(--border))" />
          <Tooltip labelFormatter={(t) => `${hhmm(Number(t))} UTC`} formatter={(v) => [`${v} ${unit}`, title]} />
          {gapRuns.map(([a, b]) => (
            <ReferenceArea key={a} x1={a} x2={b} fill="hsl(var(--muted))" fillOpacity={1} stroke="none" />
          ))}
          <Line type="linear" dataKey="value" stroke={stroke} strokeWidth={2} dot={false} connectNulls={gaps === "always"} isAnimationActive={false} />
        </LineChart>
      </div>
      {baseline === "fit" && <p className="mt-2 text-[11px] text-muted-foreground">Axis does not start at zero.</p>}
    </div>
  );
}

demo.tsxHow it is called: a minute of samples at a time with ten missing, the gap left as a gap, the top of the axis pinned.

import { TimeSeries, type Sample } from "./TimeSeries";

/** Checkout p95 in ms, one sample a minute from 14:00, and ten minutes with no samples at all. */
const T0 = Date.UTC(2026, 8, 15, 14, 0);
const VALUES: (number | null)[] = [
  90, 104, 112, 98, 120, 116, 124, 110, 128, 134, 126, 140, 146, 138, 152, 150, 144, 158, 164, 156,
  null, null, null, null, null, null, null, null, null, null,
  196, 188, 204, 174, 212, 220, 208, 241, 230, 236, 219, 248, 256, 262, 272,
];
const DATA: Sample[] = VALUES.map((value, i) => ({ t: T0 + i * 60_000, value }));

export default function Demo() {
  return (
    <div className="rounded-lg border bg-card p-4">
      <TimeSeries title="Checkout P95" interval="1m" unit="ms" data={DATA} gaps="never" max={400} />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Kraken Pro

The full order book and depth ladder are public with no account, which makes it the only display here updating several times a second that anyone can go and watch.

Trade / BTC-USD September 11, 2026 Kraken Pro, signed out (public market data) dense · dark · desktop-web
The red-green-direction entry argues that the convention should never carry the information alone, and this ladder shows the redundancy arriving for free. Asks are red and bids are green, but asks are also above the spread and bids below it, so the side is encoded twice—once in a hue that a colourblind reader may not separate, and once in a position that everyone can. Convert this panel to greyscale and it still works. The spread row between them is the other good decision: it reads "0.1 (0.0001%)", the absolute and the ratio side by side, so the number means something whether you are trading one Bitcoin or a hundred. Two details worth noticing. The quantity column runs to eight decimal places, which is correct for the asset and unreadable at a glance, and the depth bars behind each row are doing most of the actual communicating. And the "0.10" control at the top of the panel is the price increment the book is aggregated into—change it and the number of levels changes underneath you.
  • Order book and depth Two ladders sharing a price axis, with the spread labelled between them and a depth bar behind every row.
  • Red/green direction coloring Asks in red, and also above the spread. Position carries the side on its own, so greyscale survives.
  • Ratio and rate The spread as 0.1 and as 0.0001% together, which is the absolute and the normalised form on one line.
  • Delta indicator −197.0 USD and −0.26% together, over a named 24-hour base. All three of the decisions, made and stated.
  • Time series Candles rather than a line, so each interval carries open, high, low and close instead of one sampled value.
  • Order book and depth The price increment the book is aggregated into. Change it and the level count changes under the eye.
Show 4 more examples Hide the rest

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Examples / Histogram September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
This page accidentally contains the cleanest demonstration of the bucket problem I have found in a shipped product. The panel top right is the source series, a line oscillating between 24 and 32. Directly under it, the same series on automatic buckets: roughly twenty-four bars, and it is plainly bimodal, with one hump around 27 and a taller one at 30.4. To the left of that, labelled "Force timeseries into bucket size=3", is the identical data at a bucket width of three. Four bars. One hump. The second mode has not been smoothed or de-emphasised, it is simply gone, and nothing about the chart indicates that a decision was made. Neither panel is wrong. One of them answers "is this one population or two" and the other cannot, and the only difference between them is a number in a field.
Examples / State timeline and Status history September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
The most useful thing on this page is an accident of layout: the third panel down the left column draws three series as state bands, and the panel directly underneath draws the identical query as an ordinary time series. Same data, stacked one above the other. The line chart is three tangled traces oscillating between 57 and 64, and reading it means picking a series, tracing it, and thresholding it in your head. The state timeline above has already done that and shows the conclusion, and the blue stretches—below fifty—are findable without being looked for. That is the whole argument for the encoding, and Grafana has put the before and after in adjacent panels. Worth noticing too that every coloured region here carries its word inside it. LOW, HIGH, NORMAL, CRITICAL, True, False. Colour is never the only carrier on this page.
  • Status history Continuous regions with the state name written inside each one.
  • Threshold line and region Thresholds turning a numeric series into discrete bands: under 50, 50, 300.
  • Time series The same query as the panel above, untranslated. Three traces you have to threshold yourself.
  • Sequential and diverging scales One mark per sample, green through red by temperature—so green here means cold, not good.
  • Semantic status color A closed set of two, both labelled. The clearest case on the page of colour plus a word.
Grafana Heatmaps September 9, 2026 Grafana Play (signed out; no version string exposed) dense · dark · desktop-web
Grafana's own teaching dashboard for the heatmap panel, and it teaches well because it holds the data constant and varies exactly one thing. The top-left panel is the source series. The eight heatmaps are all those same numbers under different y-bucket scales: linear, log2, log10, each with and without a split, two of them clamped to a 700-15k range. Read across the grid and the argument makes itself. The linear version crushes almost everything into a band near the bottom of the axis; the log versions spread the same distribution across the full height and the dense region moves. Same data, different readings, and nothing on any single panel tells you which bucketing you're looking at except the title someone remembered to write.
  • Time series The source series. Every heatmap on the page is built from this.
  • Heatmap Linear y buckets, which is the default and the one that hides the low end.
  • Small multiples Nine panels, one variable changed. The comparison is the content.
  • Sequential and diverging scales Every panel carries its own ramp legend, 1 to 30, so the colour is readable per panel but not across them.

Tableau Public

Thousands of dashboards made by people who are not designers, published without a review step. The best available sample of what the pattern language looks like in the wild.

Shopify Customer Journey September 10, 2026 Tableau Public embed view; workbook published by Lovelytics medium · light · desktop-web
Tableau Public is the product; the design decisions here are the author's. This workbook was published by Lovelytics, so read it as what a competent analyst builds in Tableau rather than as how Tableau thinks dashboards should look. What is instructive is that it carries three separate lines of small-caps instruction—"click on metric to filter dashboard", "hover on a province to view breakdown by top 10 cities", "click on bar to view the second product purchased". Every interaction on the page needed a label, because none of them announces itself. That is the honest cost of cross-filtering: it is powerful and it is invisible until someone tells you it is there. Two other things. The tile block is nine values in a three-by-three grid, which is past the point where a strip has a reading order—the eye has to be told where to start and isn't. And the chart titled "Total sales per month" is plotting seven days, on a y-axis that begins at 500K, so a roughly twenty-five percent spread draws as a mountain range.
  • Cross-filter The tiles are the filter. It needed a line of instruction above it, because nothing about a number says it is clickable.
  • Header KPI strip Nine values in a grid rather than four to six in a row, so there is no privileged place for the eye to start.
  • Time series Titled per month, plotting seven days, on an axis starting at 500K. A 25% spread rendered as a cliff.
  • Hover detail The breakdown by city exists only on hover, so it is unavailable on touch and invisible in this screenshot.
  • Geo map with markers A choropleth of raw sales, unnormalised, so California and Texas lead partly by being large and populous.
  • Ranked list 490 against 30 for second place, so every bar below the first is a sliver and the ordering is all you get.