Skip to content
KONIGI

Dashboards / Visual representation / Threshold line and region

20 of 22

Threshold line and region

A flat metric has a line it must not cross, and the chart should show where that line is.

Updated September 10, 2026

Problem

The line is at 340ms and climbing. Whether that matters depends entirely on a number the viewer either has memorised or doesn’t have at all, and the chart is the obvious place to put it.

Solution

Draw the limit on the same axes as the data. Once it’s there, “is this bad” becomes a question about geometry rather than recall, and it can be answered from across a room.

Grafana’s implementation is worth reading as a spec. Thresholds come in two modes: Absolute, “defined by a number”, and Percentage, “defined relative to minimum or maximum”. Out of the box a panel arrives with Base = green and 80 = red in Absolute mode, which is the single most consequential default in the product, because a threshold at 80 is meaningful for CPU percentage and arbitrary for everything else. Plenty of dashboards ship with it untouched and a red panel that means nothing.

The display options are more interesting than they look. Show thresholds offers Off, As lines, As lines (dashed), As filled regions, As filled regions and lines, and the dashed variant of that. The choice between a line and a filled region is not decoration. A line says “here is the limit”. A filled region says “this whole zone is bad”, which reads faster at a distance and costs contrast against the series itself. Dashed says “this is annotation, not data”, which is exactly the distinction a viewer needs when the threshold sits close to the line it’s judging.

The harder question is what the number is doing there. A threshold on a chart is a claim that someone decided what “too much” means. If that decision lives only in the panel’s JSON, it will be wrong within a quarter and nobody will know. Error budgets are the version of this pattern that keeps its provenance: the limit derives from a stated objective, and the display shows consumption against it rather than a bare line.

Use when

There is a real limit and crossing it means something: an SLO, a capacity ceiling, a contractual latency, a safety limit. Especially useful on a chart someone monitors rather than explores.

Don’t use when

The limit is aspirational. A line drawn at a number nobody agreed to teaches viewers that lines on charts are decorative, which then costs you the next one that matters.

Trade-offs

A threshold is a single number standing in for a judgement, and it hides the judgement. It also invites binary reading of a continuous quantity: 79 and 81 are nearly identical states rendered as opposite colours. Static thresholds age badly, because traffic grows and the limit doesn’t. They fire constantly on noisy metrics unless the alert behind them has hysteresis the chart never shows. And a filled region competes for the same visual channel as the data, so a busy chart can end up with the warning zone more prominent than the series crossing into it.

Checklist

  • Who set this number, when, and against what?
  • Is it absolute or relative to the axis range, and does the panel say which?
  • Is this still the default 80, and if so, is 80 meaningful for this metric?
  • Line or region, and does the choice suit the reading distance?
  • Is the threshold visually distinguishable from the data series?
  • Does crossing it trigger anything, or is it decorative?
  • Does the alert behind it use the same number the chart draws?
  • How does a metric that hovers at the line render, and does it flicker?
  • If this is an SLO, does the panel show budget consumed rather than only the current value?
  • What happens to the threshold when the y-axis autoscales past it?

Compare

Grafana ships thresholds as a field property with a colour ramp, six display styles and a default that survives into production more often than it should, so the pattern is everywhere in Grafana dashboards and correct in fewer of them. Honeycomb replaces the bare line with an error budget: the objective is declared, and the chart shows how much of the allowance has been spent and how fast, which answers “should I act now” rather than only “am I over”. Datadog ties the drawn limit to the monitor definition, so the line on the chart and the condition that pages someone are the same object rather than two numbers that drift apart. Netdata ships alarm limits with its collectors, so the thresholds arrive pre-set by whoever wrote the integration, which is a good default and a bad excuse not to revisit them.

Anomaly band is the answer when there is no fixed limit and “normal” depends on the hour. Alert rule is what should be attached to the line if it means anything. Metric targets is where the number ought to be defined and owned. Semantic status color decides what crossing it looks like. Time series is the chart this pattern is drawn on.

Threshold line and region anatomy A limit drawn on the same axes as the series it judges, so "is this bad" becomes geometry rather than recall. Below, the three ways to draw it—a solid line, a dashed line, and a filled region—and what each one says. The limit on the data's own axes Checkout error rate 1.0% 1 2 1 THE LIMIT Once it's on the chart, "is this bad" stops being a question about recall. It can be answered from across a room. 2 WHERE IT CAME FROM A threshold is a claim that somebody decided what too much means. If that decision lives only in the panel's JSON, it's wrong within a quarter and nobody finds out. Three renderings, three different claims "Here is the limit" "This is annotation" "This whole zone is bad" A panel shipping with its default threshold at 80 is red for a reason nobody chose.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The rule behind an alert, drawn where the data is. A number that is about to page someone should show the line it is approaching rather than leaving the reader to remember what it was.

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

Checkout error rate

13:3013:5514:400.0%0.4%0.8%1.4%1.0%

over 1.0% for 15 min · limit set by payments on-call, 2026-06-02, the SLO allows 0.5% a week and a page at 1.0% leaves budget to act · alert rule

ThresholdChart.tsxThe limit on the data's axes, with its owner and reason attached, and the span over it shaded from the crossing.

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

/**
 * A threshold is a claim that somebody decided what too much means, so the
 * decision travels with the number. A limit with no owner is the default 80
 * that shipped with the panel, red for a reason nobody chose.
 */
export type Threshold = {
  value: number;
  /** Who set it, when, and against what. All three, or the number is decoration. */
  setBy: string;
  on: string;
  reason: string;
  /** Three renderings, three claims. A line says "here is the limit", dashed says "this is annotation, not data", a region says "this whole zone is bad". */
  style: "line" | "dashed" | "region";
  /** The alert rule that uses this same number, so the two cannot drift apart. */
  href?: string;
};

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

const hhmm = (t: number) => {
  const d = new Date(t);
  return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
};

/** Where the series crosses the limit, interpolated, so the shaded span starts at the crossing rather than at the nearest sample. */
export function breaches(data: Sample[], limit: number): [number, number][] {
  const out: [number, number][] = [];
  let start: number | null = null;
  for (let i = 1; i < data.length; i++) {
    const a = data[i - 1], b = data[i];
    const cross = a.t + ((limit - a.value) / (b.value - a.value)) * (b.t - a.t);
    if (a.value <= limit && b.value > limit) start = cross;
    if (a.value > limit && b.value <= limit && start !== null) { out.push([start, cross]); start = null; }
  }
  if (start !== null) out.push([start, data[data.length - 1].t]);
  return out;
}

const SERIES = "hsl(var(--chart-1))";
const LIMIT = "hsl(var(--status-critical))";

export function ThresholdChart({ title, data, threshold, format, width = 340, height = 150 }: {
  title: string;
  data: Sample[];
  threshold: Threshold;
  /** Renders a value the way the axis does, so the limit's label and the ticks agree. */
  format: (v: number) => string;
  width?: number;
  height?: number;
}) {
  const over = breaches(data, threshold.value);
  const top = Math.max(threshold.value, ...data.map((d) => d.value)) * 1.15;
  const label = { value: format(threshold.value), position: "insideTopRight" as const, fill: LIMIT, fontSize: 10 };

  return (
    <div className="rounded-lg border bg-card p-4">
      <p className="border-b pb-2 text-[11px] uppercase tracking-wide text-muted-foreground">{title}</p>
      <div className="mt-3 overflow-x-auto">
        <LineChart width={width} height={height} data={data} margin={{ top: 8, right: 8, bottom: 0, left: -20 }}>
          <XAxis interval={0} dataKey="t" type="number" domain={["dataMin", "dataMax"]} tickFormatter={hhmm} tick={{ fontSize: 10 }} stroke="hsl(var(--border))" />
          <YAxis domain={[0, top]} tickFormatter={format} tick={{ fontSize: 10 }} stroke="hsl(var(--border))" />
          {threshold.style === "region"
            ? <ReferenceArea y1={threshold.value} y2={top} fill={LIMIT} fillOpacity={0.15} stroke="none" label={label} />
            : <ReferenceLine y={threshold.value} stroke={LIMIT} strokeWidth={2} strokeDasharray={threshold.style === "dashed" ? "6 4" : undefined} label={label} />}
          {over.map(([a, b]) => (
            <ReferenceArea key={a} x1={a} x2={b} y1={threshold.value} y2={top} fill={LIMIT} fillOpacity={0.15} stroke="none" />
          ))}
          <Line type="linear" dataKey="value" stroke={SERIES} strokeWidth={2} dot={false} isAnimationActive={false} />
        </LineChart>
      </div>
      <p className="mt-2 text-[10px] text-muted-foreground">
        {over.length > 0 && <span className="text-status-critical">over {format(threshold.value)} for {Math.round(over.reduce((n, [a, b]) => n + b - a, 0) / 60_000)} min · </span>}
        limit set by {threshold.setBy}, {threshold.on}, {threshold.reason}
        {threshold.href && <> · <a href={threshold.href} className="underline underline-offset-2">alert rule</a></>}
      </p>
    </div>
  );
}

demo.tsxHow it is called: an afternoon of checkout errors crossing 1.0% for twenty minutes.

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

/**
 * Checkout error rate through an afternoon, crossing the 1.0% limit for
 * about twenty minutes. The limit carries who set it and why, and links to
 * the alert that fires on the same number.
 */
const T0 = Date.UTC(2026, 8, 15, 13, 30);
const DATA: Sample[] = [0.2, 0.28, 0.22, 0.42, 1.12, 1.22, 0.52, 0.6].map((value, i) => ({ t: T0 + i * 600_000, value }));

export default function Demo() {
  return (
    <ThresholdChart
      title="Checkout error rate"
      data={DATA}
      format={(v) => `${v.toFixed(1)}%`}
      threshold={{
        value: 1.0,
        setBy: "payments on-call",
        on: "2026-06-02",
        reason: "the SLO allows 0.5% a week and a page at 1.0% leaves budget to act",
        style: "line",
        href: "/alerts/checkout-error-rate",
      }}
    />
  );
}
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.

Grafana

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

Examples / Gauge September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
Nine gauges, and the page makes Few's argument against them without meaning to. The panel at bottom left holds seven arcs reading 30.6, 30.8, 30.8, 30.9, 30.9, 31.0 and 31.3 GB. Seven squares, seven needles, to say that seven disks are all about the same. The same seven values as a bullet graph would be seven short bars in a fraction of the height, and the one that was different would be obvious rather than requiring you to read each label. What the gauges do carry is a bounded range where the endpoints mean something, which is the case the pattern survives: these are percentages and capacities, not rates. The panel beside them is where it stops working—three circular gauges at 40.8, 20 and 48.6 GB, all with a red arc, so a value and another value two and a half times larger get the same verdict.
  • Gauge and dial Seven arcs to report that seven disks agree. This is the area argument in one panel.
  • Sparkline A sparkline inside the arc: the same series encoded twice in one square.
  • Sequential and diverging scales The arc runs pink through green with no perceptual order, so position on it means nothing without the number.
  • Semantic status color 40.8, 20 and 48.6 all arc red. The threshold fires for everything, so it says nothing.
  • Threshold line and region The coloured band around the rim is the threshold, drawn as a region rather than a line.
Show 1 more example 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 / 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.