Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Time series

‹ 21 of 22 ›

Time series

People need to see how a value changed over time and when a change began.

Updated September 9, 2026

Problem

People need to see when a value changed and how it behaved before and after that change.

Solution

Plot time horizontally and the measured value vertically, with clear units and a readable scale. Connect observations only when the connection accurately represents the data.

Show missing periods clearly. State the sampling or aggregation interval when it affects interpretation, especially as the selected range changes.

Choose the vertical range deliberately. A narrow range can reveal small changes but also make them look dramatic. Use consistent scales when charts are intended for direct comparison.

Use when

Timing, duration, trends, or the sequence of changes matters to the task.

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

Lines can imply continuous measurement between samples. Aggregation may hide brief spikes or outages, and averages can smooth away useful detail. Automatic scales can make small changes appear large.

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 a compact trend beside a value. Anomaly band and Threshold line add expected ranges or limits.

Annotation places events on the timeline. Time-range picker and Zoom and pan control the period being inspected.

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

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Kraken Pro

A trading interface with price charts, an order book, and market-depth displays.

Trade / BTC-USD September 11, 2026 Kraken Pro, signed out (public market data) dense · dark · desktop-web
The order book separates asks and bids by position as well as red and green. A labeled spread sits between them and includes both an absolute value and a percentage. Depth bars behind the rows help compare quantities that are difficult to scan as long decimal values. A price-increment control changes how orders are grouped into levels.
Show 4 more examples Hide the rest

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Histogram September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
The source series appears with two histogram settings. Automatic intervals reveal two peaks, while the wider bucket-size setting combines the data into four bars and makes that distinction harder to see. The underlying data is the same. This is a useful example of why interval size belongs in the interpretation of a histogram, not only in its settings.
Examples / State timeline and Status history September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
This page places a state timeline above a time-series view of the same query. The timeline converts numeric ranges into named states, making the periods easier to scan. The line chart keeps the numeric variation available for closer reading. Labels inside the colored regions explain their meaning, reducing reliance on color alone.
Grafana Heatmaps September 9, 2026 Grafana Play (signed out; no version string exposed) dense · dark · desktop-web
This teaching dashboard shows the source series and several heatmaps built from it. Different value scales and bucket settings change the apparent distribution. The linear view concentrates much of the data near the bottom; logarithmic views spread it across more of the chart. Titles identify the settings, making the grid useful for comparing their effects.
  • Time series The source series supplies the data for the heatmaps.
  • Heatmap Linear value intervals concentrate much of this distribution near the bottom.
  • Small multiples Repeated panels make the effects of different settings easier to compare.
  • Sequential and diverging scales Each heatmap has its own legend; check the ranges before comparing colors.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Shopify Customer Journey September 10, 2026 Tableau Public embed view; workbook published by Lovelytics medium · light · desktop-web
This Lovelytics workbook uses clickable metrics and bars to filter or reveal detail. Instructions explain these interactions because the marks do not clearly look interactive. Nine summary values compete for attention in a grid. The sales chart also has a mismatch between its monthly title and the displayed dates, while its truncated vertical scale emphasizes variation. These choices make labels and interaction cues especially important.
  • Cross-filter An instruction explains that metric tiles filter the dashboard.
  • Header KPI strip Nine values share a grid without a clear starting priority.
  • Time series The title and displayed period differ, and the vertical axis starts above zero.
  • Hover detail City details are described as available on hover.
  • Geo map with markers The map shows raw sales totals, which also reflect differences between populations.
  • Ranked list One much larger value leaves the remaining bars difficult to compare.